From aac94020496aa20b1a19663f68acb4560f4b9fec Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:36:57 -0500 Subject: [PATCH 01/14] Now can scan for duplicate files --- .gitignore | 1 + Parallel.Cli/Commands/DecryptCommand.cs | 2 +- Parallel.Cli/Commands/DuplicatesCommand.cs | 23 ++----------------- Parallel.Cli/Commands/EncryptCommand.cs | 2 +- Parallel.Core/Settings/ParallelSettings.cs | 12 ++++++++++ Parallel.Core/Settings/ProfileConfig.cs | 26 +++++----------------- 6 files changed, 23 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 9a9d1e7..707d565 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.userosscache *.sln.docstates *.sln +*.lnk # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs index ea28e9c..4aa9112 100644 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -56,7 +56,7 @@ public DecryptCommand() : base("decrypt", "Decrypts a file or directory.") private async Task DecryptDirectoryAsync(string path, string masterKey) { CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); - string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".gz")).ToArray(); + string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).ToArray(); if (files.Length == 0) { CommandLine.WriteLine("No files found to decrypt!", ConsoleColor.Yellow); diff --git a/Parallel.Cli/Commands/DuplicatesCommand.cs b/Parallel.Cli/Commands/DuplicatesCommand.cs index 4d5e2aa..6a4ad92 100644 --- a/Parallel.Cli/Commands/DuplicatesCommand.cs +++ b/Parallel.Cli/Commands/DuplicatesCommand.cs @@ -14,34 +14,15 @@ namespace Parallel.Cli.Commands public class DuplicatesCommand : Command { private Argument sourceArg = new("path", "The directory to scan."); - private Option credsOpt = new(["--credentials", "-c"], "The file system credentials to use."); public DuplicatesCommand() : base("duplicates", "Scans a directory for duplicate files.") { this.AddArgument(sourceArg); - this.AddOption(credsOpt); - this.SetHandler((path, config) => - { - ProfileConfig profile = ProfileConfig.Load(Program.Settings, config); - ScanForDuplicateFiles(path, profile); - }, sourceArg, credsOpt); + this.SetHandler(ScanForDuplicateFiles, sourceArg); } - private void ScanForDuplicateFiles(string path, ProfileConfig profile) + private void ScanForDuplicateFiles(string path) { - IBackupManager backup = BackupManager.CreateNew(profile); - if (!backup.Initialize()) - { - CommandLine.WriteLine("Failed to connect to backup file system!", ConsoleColor.Red); - return; - } - - if (!Directory.Exists(path)) - { - CommandLine.WriteLine("The provided directory is invalid!", ConsoleColor.Yellow); - return; - } - CommandLine.WriteLine($"Scanning for duplicate files in {path}...", ConsoleColor.DarkGray); Dictionary duplicates = FileScanner.GetDuplicateFiles(path); Dictionary result = duplicates.ToDictionary(k => k.Key, v => v.Value.Select(l => l.LocalPath).ToArray()); diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs index dd048c4..aea212d 100644 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -59,7 +59,7 @@ public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") private async Task EncryptDirectoryAsync(string path, string masterKey) { CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); - string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".gz")).ToArray(); + string[] files = Directory.EnumerateFiles(path, $"*", SearchOption.AllDirectories).ToArray(); if (files.Length == 0) { CommandLine.WriteLine("No files found to encrypt!", ConsoleColor.Yellow); diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index 412a8b5..caaec4e 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -66,5 +66,17 @@ public void Save() if (!Directory.Exists(PathBuilder.ProgramData)) Directory.CreateDirectory(PathBuilder.ProgramData); File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); } + + /// + /// + /// + /// + public void ForEachProfile(Action action) + { + foreach (ProfileConfig? profile in Profiles.Select(ProfileConfig.Load)) + { + if (profile != null) action(profile); + } + } } } \ No newline at end of file diff --git a/Parallel.Core/Settings/ProfileConfig.cs b/Parallel.Core/Settings/ProfileConfig.cs index 5878534..0d54fed 100644 --- a/Parallel.Core/Settings/ProfileConfig.cs +++ b/Parallel.Core/Settings/ProfileConfig.cs @@ -112,18 +112,11 @@ public ProfileConfig(string name, DatabaseCredentials database, FileSystemCreden /// /// Loads settings from a file. /// - public static ProfileConfig Load(string path) + public static ProfileConfig? Load(string path) { - if (File.Exists(path)) - { - string json = File.ReadAllText(path); - return JsonConvert.DeserializeObject(json); - } - else - { - string name = Path.GetFileNameWithoutExtension(path); - return new ProfileConfig(name, new DatabaseCredentials(), new FileSystemCredentials()); - } + if (!File.Exists(path)) return null; + string json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); } /// @@ -132,15 +125,8 @@ public static ProfileConfig Load(string path) /// A instance. public static ProfileConfig? Load(ParallelSettings settings, string name) { - ProfileConfig profile = Load(settings.Profiles.First()); - if (!string.IsNullOrEmpty(name)) - { - string path = Path.Combine(ParallelSettings.ProfilesDir, name + ".json"); - if (!File.Exists(path)) return null; - profile = Load(Path.GetFileNameWithoutExtension(path)); - } - - return profile; + ProfileConfig? profile = Load(settings.Profiles.First()); + return string.IsNullOrEmpty(name) ? profile : Load(Path.Combine(ParallelSettings.ProfilesDir, name + ".json")); } /// From 2b9d004512c9f2bed36e1179b910c76ba99b0ade Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:06:05 -0500 Subject: [PATCH 02/14] Refactored compressing files code --- Parallel.Cli/Commands/UnzipCommand.cs | 18 ++---------------- Parallel.Cli/Commands/ZipCommand.cs | 18 ++---------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/Parallel.Cli/Commands/UnzipCommand.cs b/Parallel.Cli/Commands/UnzipCommand.cs index 385bad6..97c0030 100644 --- a/Parallel.Cli/Commands/UnzipCommand.cs +++ b/Parallel.Cli/Commands/UnzipCommand.cs @@ -33,27 +33,13 @@ public UnzipCommand() : base("unzip", "Unzips files in a directory.") CommandLine.WriteLine($"Unzipping {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); _totalTasks = files.Length; - foreach (string file in files) - { - StartDecompressFile(file, keep); - } - + _tasks.AddRange(files.Select(file => Task.Run(() => DecompressFile(file, keep)))); await Task.WhenAll(_tasks); + CommandLine.WriteLine($"Successfully unzipped {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); }, sourceArg, keepOpt); } - private void StartDecompressFile(string path, bool keep) - { - Task decompTask = Task.Run(() => - { - DecompressFile(path, keep); - }); - - decompTask.ContinueWith(t => t.Dispose()); - _tasks.Add(decompTask); - } - private void DecompressFile(string path, bool keep) { if (File.Exists(path)) diff --git a/Parallel.Cli/Commands/ZipCommand.cs b/Parallel.Cli/Commands/ZipCommand.cs index 3dd1a5d..83722f7 100644 --- a/Parallel.Cli/Commands/ZipCommand.cs +++ b/Parallel.Cli/Commands/ZipCommand.cs @@ -35,27 +35,13 @@ public ZipCommand() : base("zip", "Zips files in a directory.") CommandLine.WriteLine($"Zipping {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); _totalTasks = files.Length; - foreach (string file in files) - { - StartCompressFile(file, keep); - } - + _tasks.AddRange(files.Select(file => Task.Run(() => CompressFile(file, keep)))); await Task.WhenAll(_tasks); + CommandLine.WriteLine($"Successfully zipped {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); }, sourceArg, keepOpt); } - private void StartCompressFile(string path, bool keep) - { - Task compTask = Task.Run(() => - { - CompressFile(path, keep); - }); - - compTask.ContinueWith(t => t.Dispose()); - _tasks.Add(compTask); - } - private void CompressFile(string path, bool keep) { if (File.Exists(path)) From 5c80fab7941171381aad4b52fa7e504b391367dd Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:27:31 -0500 Subject: [PATCH 03/14] Added quick start to readme --- README.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3fc26d..33b571b 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,30 @@ Your computer already gives you enough to fight with — your files don't have t -## 📦 Installation - -> Coming soon — Parallel is currently in active development. Stay tuned for install instructions, binaries, and package manager support. +## 📦 Quick Start Guide +#### 1. Install Parallel +Download the latest [release](https://github.com/TheGuitarleader/Parallel/releases/latest) or build from source: +``` +git clone https://github.com/TheGuitarleader/Parallel +cd Parallel +dotnet build +``` +#### 2. Set Up Your Vaults +Vaults are storage targets where Parallel sends and recieves files. This can be an external drive, NAS share, SSH server, or S3-compatible cloud. +``` +parallel vaults create +``` +*Note: All vaults are saved as JSON in `%AppData%\Parallel\Vaults` for easy importing and exporting.* +#### 3. Push Files to Vaults +Parallel can push all changed files on the system with: +``` +parallel push +``` +Or you can specify a path which can be a file or folder. +``` +parallel push --path "C:\Windows\System32" +parallel push -p "C:\Windows\System32\cmd.exe" +``` ## 🧪 Status From 8a71619cd2990029a8a02b0d021556892a963a38 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:28:07 -0500 Subject: [PATCH 04/14] Renamed profiles to vaults --- .../{ConfigCommand.cs => VaultsCommand.cs} | 30 +++++++------- Parallel.Core/Settings/ParallelSettings.cs | 11 +++--- .../{ProfileConfig.cs => VaultConfig.cs} | 39 ++++++++++--------- 3 files changed, 43 insertions(+), 37 deletions(-) rename Parallel.Cli/Commands/{ConfigCommand.cs => VaultsCommand.cs} (74%) rename Parallel.Core/Settings/{ProfileConfig.cs => VaultConfig.cs} (81%) diff --git a/Parallel.Cli/Commands/ConfigCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs similarity index 74% rename from Parallel.Cli/Commands/ConfigCommand.cs rename to Parallel.Cli/Commands/VaultsCommand.cs index 661a68d..d6b1492 100644 --- a/Parallel.Cli/Commands/ConfigCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -4,27 +4,31 @@ using Parallel.Cli.Utils; using Parallel.Core.Database; using Parallel.Core.IO.FileSystem; +using Parallel.Core.Security; using Parallel.Core.Settings; using Parallel.Core.Utils; namespace Parallel.Cli.Commands { - public class ConfigCommand : Command + public class VaultsCommand : Command { - private Option configOpt = new(["--config", "-c"], "The profile configuration to use."); + private Option configOpt = new(["--config", "-c"], "The vault configuration to use."); - private Command addCmd = new("add", "Adds a new profile configuration."); - private Command editCmd = new("edit", "Edits a profile configuration."); - private Command viewCmd = new("view", "Shows the profile configuration."); - private Command setCmd = new("set", "Sets a new profile configuration."); - private Command delCmd = new("delete", "Deletes a profile configuration."); + private Command addCmd = new("add", "Adds a new vault configuration."); + private Command editCmd = new("edit", "Edits a vault configuration."); + private Command viewCmd = new("view", "Shows the vault configuration."); + private Command setCmd = new("set", "Sets a new vault configuration."); + private Command delCmd = new("delete", "Deletes a vault configuration."); - public ConfigCommand() : base("config", "View or edit the profile configurations.") + public VaultsCommand() : base("vaults", "View or edit the vaults.") { this.SetHandler(() => { - //ProfileConfig profile = ProfileConfig.Load(); - CommandLine.WriteLine($"Current profile: '{Program.Settings.Profiles.FirstOrDefault()}'"); + CommandLine.WriteLine("Active vaults:"); + Program.Settings.ForEachVault(vault => + { + CommandLine.WriteLine(vault.Name); + }); }); this.AddCommand(addCmd); @@ -70,10 +74,10 @@ public ConfigCommand() : base("config", "View or edit the profile configurations fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); string profileName = CommandLine.ReadString("Profile Name"); - ProfileConfig profile = new ProfileConfig(profileName, dbc, fsc); - profile.SaveToFile(); + VaultConfig vault = new VaultConfig(profileName, dbc, fsc); + vault.SaveToFile(); - CommandLine.WriteLine($"Saved new connection profile: '{profile.Name}'"); + CommandLine.WriteLine($"Saved new connection vault: '{vault.Name}'"); }); this.AddCommand(setCmd); diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index caaec4e..bc3a562 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -19,7 +19,7 @@ public class ParallelSettings /// /// The location of files for different file system credentials./>. /// - public static string ProfilesDir { get; } = Path.Combine(PathBuilder.ProgramData, "Profiles"); + public static string VaultsDir { get; } = Path.Combine(PathBuilder.ProgramData, "Vaults"); /// /// The address that will accept incoming commands. @@ -37,7 +37,7 @@ public class ParallelSettings /// The profiles to use. /// The CLI defaults to the first in the list. /// - public HashSet Profiles { get; } = new HashSet(); + public HashSet Vaults { get; } = new HashSet(); /// @@ -71,11 +71,12 @@ public void Save() /// /// /// - public void ForEachProfile(Action action) + public void ForEachVault(Action action) { - foreach (ProfileConfig? profile in Profiles.Select(ProfileConfig.Load)) + foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) { - if (profile != null) action(profile); + VaultConfig? vault = VaultConfig.Load(path); + if (vault != null) action(vault); } } } diff --git a/Parallel.Core/Settings/ProfileConfig.cs b/Parallel.Core/Settings/VaultConfig.cs similarity index 81% rename from Parallel.Core/Settings/ProfileConfig.cs rename to Parallel.Core/Settings/VaultConfig.cs index 0d54fed..60f2b84 100644 --- a/Parallel.Core/Settings/ProfileConfig.cs +++ b/Parallel.Core/Settings/VaultConfig.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.FileSystem; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.Settings @@ -12,15 +13,15 @@ namespace Parallel.Core.Settings /// /// Represents a back-up connection. /// - public class ProfileConfig + public class VaultConfig { /// - /// A unique hash used to identify the profile. + /// A unique hash used to identify the vault. /// public string Id { get; } = HashGenerator.GenerateHash(12, true); /// - /// The name of the profile. + /// The name of the vault. /// public string Name { get; set; } = "Default"; @@ -80,14 +81,14 @@ public class ProfileConfig /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// /// /// /// [JsonConstructor] - public ProfileConfig(string id, string name, DatabaseCredentials database, FileSystemCredentials fileSystem) + public VaultConfig(string id, string name, DatabaseCredentials database, FileSystemCredentials fileSystem) { Id = id; Name = name; @@ -96,12 +97,12 @@ public ProfileConfig(string id, string name, DatabaseCredentials database, FileS } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// /// /// - public ProfileConfig(string name, DatabaseCredentials database, FileSystemCredentials fileSystem) + public VaultConfig(string name, DatabaseCredentials database, FileSystemCredentials fileSystem) { Id = HashGenerator.GenerateHash(12, true); Name = name; @@ -112,39 +113,39 @@ public ProfileConfig(string name, DatabaseCredentials database, FileSystemCreden /// /// Loads settings from a file. /// - public static ProfileConfig? Load(string path) + public static VaultConfig? Load(string path) { if (!File.Exists(path)) return null; string json = File.ReadAllText(path); - return JsonConvert.DeserializeObject(json); + return JsonConvert.DeserializeObject(json); } /// /// Loads credentials from the app configuration. /// - /// A instance. - public static ProfileConfig? Load(ParallelSettings settings, string name) + /// A instance. + public static VaultConfig? Load(ParallelSettings settings, string name) { - ProfileConfig? profile = Load(settings.Profiles.First()); - return string.IsNullOrEmpty(name) ? profile : Load(Path.Combine(ParallelSettings.ProfilesDir, name + ".json")); + VaultConfig? vault = Load(settings.Vaults.First()); + return string.IsNullOrEmpty(name) ? vault : Load(Path.Combine(ParallelSettings.VaultsDir, name + ".json")); } /// /// Saves credentials to a file. /// - /// The current profile to save. - public static void Save(ProfileConfig profile) + /// The current vault to save. + public static void Save(VaultConfig vault) { - if (!Directory.Exists(ParallelSettings.ProfilesDir)) Directory.CreateDirectory(ParallelSettings.ProfilesDir); - string path = Path.Combine(ParallelSettings.ProfilesDir, profile.Name + ".json"); - Log.Debug($"Saving profile file: {path}"); + if (!Directory.Exists(ParallelSettings.VaultsDir)) Directory.CreateDirectory(ParallelSettings.VaultsDir); + string path = Path.Combine(ParallelSettings.VaultsDir, vault.Name + ".json"); + Log.Debug($"Saving vault file: {path}"); if (!File.Exists(path)) { Log.Debug("Creating file -> " + path); File.Create(path).Close(); } - File.WriteAllText(path, JsonConvert.SerializeObject(profile, Formatting.Indented)); + File.WriteAllText(path, JsonConvert.SerializeObject(vault, Formatting.Indented)); } /// From 4f7b01acf3813cd8a2534688ecd153f7f5c5b97a Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:28:56 -0500 Subject: [PATCH 05/14] Renamed backup to sync for pushing and pulling files --- Parallel.Core/Database/IDatabase.cs | 14 ++++---- .../BaseSyncManager.cs} | 32 ++++++++----------- .../DeltaSyncManager.cs} | 14 ++++---- .../FileSyncManager.cs} | 17 +++++----- .../ISyncManager.cs} | 25 ++++----------- .../SyncManager.cs} | 12 +++---- 6 files changed, 49 insertions(+), 65 deletions(-) rename Parallel.Core/IO/{Backup/BaseFileManager.cs => Syncing/BaseSyncManager.cs} (52%) rename Parallel.Core/IO/{Backup/DeltaBackupManager.cs => Syncing/DeltaSyncManager.cs} (51%) rename Parallel.Core/IO/{Backup/FileBackupManager.cs => Syncing/FileSyncManager.cs} (81%) rename Parallel.Core/IO/{Backup/IBackupManager.cs => Syncing/ISyncManager.cs} (63%) rename Parallel.Core/IO/{Backup/BackupManager.cs => Syncing/SyncManager.cs} (53%) diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 594674f..c39e5f8 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -29,19 +29,19 @@ public enum HistoryType Cloned, /// - /// A file that has been deleted from the backup. + /// A file that has been deleted from the vault. /// Pruned, /// - /// A file that was deleted and has been restored. + /// A file that was pulled from the vault. /// - Restored, + Pulled, /// - /// A newly synced file. + /// A file that was pushed to the vault. /// - Synced + Pushed } /// @@ -50,7 +50,7 @@ public enum HistoryType public interface IDatabase { /// - /// The identifier to the profile for this database. + /// The identifier to the vault for this database. /// string ProfileId { get; } @@ -93,7 +93,7 @@ public interface IDatabase #endregion - Task> GetFilesAsync(string path, bool b); + Task> GetFilesAsync(string path, bool deleted); Task GetFileAsync(string path); } } \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/BaseFileManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs similarity index 52% rename from Parallel.Core/IO/Backup/BaseFileManager.cs rename to Parallel.Core/IO/Syncing/BaseSyncManager.cs index 53f9b7e..9d6933d 100644 --- a/Parallel.Core/IO/Backup/BaseFileManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -2,20 +2,20 @@ using Parallel.Core.Database; using Parallel.Core.Diagnostics; -using Parallel.Core.Events; +using Parallel.Core.IO.Backup; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Represents the base way of backing up files to an associated file system. /// - public abstract class BaseFileManager : IBackupManager + public abstract class BaseSyncManager : ISyncManager { /// - public ProfileConfig Profile { get; } + public VaultConfig Vault { get; } /// public IDatabase Database { get; set; } @@ -23,20 +23,14 @@ public abstract class BaseFileManager : IBackupManager /// public IFileSystem FileSystem { get; set; } - /// - public string MachineName { get; } = Environment.MachineName; - - /// - public string RootFolder { get; set; } - /// /// /// - /// - public BaseFileManager(ProfileConfig profile) + /// + public BaseSyncManager(VaultConfig vault) { - FileSystem = FileSystemManager.CreateNew(profile.FileSystem); - Profile = profile; + FileSystem = FileSystemManager.CreateNew(vault.FileSystem); + Vault = vault; } /// @@ -44,10 +38,10 @@ public virtual bool Initialize() { try { - Database = DatabaseConnection.CreateNew(Profile); + Database = DatabaseConnection.CreateNew(Vault); bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Profile.IgnoreDirectories.Add(Profile.FileSystem.RootDirectory); - if (Profile != null) Profile.SaveToFile(); + Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); + if (Vault != null) Vault.SaveToFile(); return fsInit; } catch (Exception ex) @@ -58,9 +52,9 @@ public virtual bool Initialize() } /// - public abstract Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress); + public abstract Task PushFilesAsync(SystemFile[] files, IProgressReporter progress); /// - public abstract Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress); + public abstract Task PullFilesAsync(SystemFile[] files, IProgressReporter progress); } } \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/DeltaBackupManager.cs b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs similarity index 51% rename from Parallel.Core/IO/Backup/DeltaBackupManager.cs rename to Parallel.Core/IO/Syncing/DeltaSyncManager.cs index 20c2084..10663d6 100644 --- a/Parallel.Core/IO/Backup/DeltaBackupManager.cs +++ b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs @@ -4,27 +4,27 @@ using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Represents the way to clone files to an associated file system using file deltas. /// - public class DeltaBackupManager : BaseFileManager + public class DeltaSyncManager : BaseSyncManager { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// - public DeltaBackupManager(ProfileConfig profile) : base(profile) { } + /// + public DeltaSyncManager(VaultConfig vault) : base(vault) { } /// - public override Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress) + public override Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) { throw new NotImplementedException(); } /// - public override Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress) + public override Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) { throw new NotImplementedException(); } diff --git a/Parallel.Core/IO/Backup/FileBackupManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs similarity index 81% rename from Parallel.Core/IO/Backup/FileBackupManager.cs rename to Parallel.Core/IO/Syncing/FileSyncManager.cs index 4cd4427..806f442 100644 --- a/Parallel.Core/IO/Backup/FileBackupManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -5,6 +5,7 @@ using Parallel.Core.Diagnostics; using Parallel.Core.Events; using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; @@ -13,19 +14,19 @@ namespace Parallel.Core.IO.Backup /// /// Represents the way to archive files to an associated file system. /// - public class FileBackupManager : BaseFileManager + public class FileSyncManager : BaseSyncManager { private List _tasks = new List(); private int _totalFiles; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// - public FileBackupManager(ProfileConfig profile) : base(profile) { } + /// + public FileSyncManager(VaultConfig vault) : base(vault) { } /// - public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress) + public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) { if (!files.Any()) return; SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); @@ -47,7 +48,7 @@ public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporte if (remote is not null) { file.RemoteSize = remote.RemoteSize; - await Database.AddHistoryAsync(file.LocalPath, HistoryType.Synced); + await Database.AddHistoryAsync(file.LocalPath, HistoryType.Pushed); await Database.AddFileAsync(file); } } @@ -55,7 +56,7 @@ public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporte } /// - public override async Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress) + public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) { SystemFile[] restoreFiles = files.Where(f => f.Deleted).ToArray(); @@ -66,7 +67,7 @@ public override async Task RestoreFilesAsync(SystemFile[] files, IProgressReport { SystemFile file = files[i]; Log.Information($"Restoring file: {file.LocalPath}..."); - file.RemotePath = PathBuilder.Remote(file.LocalPath, Profile.FileSystem); + file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault.FileSystem); } } } diff --git a/Parallel.Core/IO/Backup/IBackupManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs similarity index 63% rename from Parallel.Core/IO/Backup/IBackupManager.cs rename to Parallel.Core/IO/Syncing/ISyncManager.cs index d819857..d634a85 100644 --- a/Parallel.Core/IO/Backup/IBackupManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -12,12 +12,12 @@ namespace Parallel.Core.IO.Backup /// /// Defines the methods needed for backing up a file system. /// - public interface IBackupManager + public interface ISyncManager { /// - /// The back-up connection profile. + /// The back-up connection vault. /// - public ProfileConfig Profile { get; } + public VaultConfig Vault { get; } /// /// The associated database connection. @@ -29,17 +29,6 @@ public interface IBackupManager /// IFileSystem FileSystem { get; set; } - /// - /// The current machine name. - /// - string MachineName { get; } - - /// - /// The root directory of the back-up. - /// - string RootFolder { get; set; } - - /// /// Initializes the backup manager by logging into the and /// @@ -47,17 +36,17 @@ public interface IBackupManager bool Initialize(); /// - /// Backs up a path. Can be either a file or directory. + /// Pushes an array of files to a vault. /// /// /// - Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress); + Task PushFilesAsync(SystemFile[] files, IProgressReporter progress); /// - /// Restores a path. Can be either a file or directory. + /// Pulls an array of files from a vault. /// /// /// - Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress); + Task PullFilesAsync(SystemFile[] files, IProgressReporter progress); } } \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/BackupManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs similarity index 53% rename from Parallel.Core/IO/Backup/BackupManager.cs rename to Parallel.Core/IO/Syncing/SyncManager.cs index 875c06f..f5b4655 100644 --- a/Parallel.Core/IO/Backup/BackupManager.cs +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -11,18 +11,18 @@ namespace Parallel.Core.IO.Backup { /// - /// Represents the way manage s. + /// Represents the way manage s. /// - public static class BackupManager + public static class SyncManager { /// - /// Creates a new instance of an . + /// Creates a new instance of an . /// - /// + /// /// - public static IBackupManager CreateNew(ProfileConfig profile) + public static ISyncManager CreateNew(VaultConfig vault) { - return new FileBackupManager(profile); + return new FileSyncManager(vault); } } } \ No newline at end of file From b3e136b54525fccff765b42ce9ab6052c8cff230 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:30:00 -0500 Subject: [PATCH 06/14] Added checksums to files for better change detection --- Parallel.Cli/Commands/DecryptCommand.cs | 12 +++---- Parallel.Cli/Commands/EncryptCommand.cs | 13 ++++---- .../Database/Contexts/SqliteContext.cs | 12 +++---- Parallel.Core/Database/DatabaseConnection.cs | 8 ++--- Parallel.Core/IO/Recovery/RecoveryManager.cs | 16 ++++----- Parallel.Core/Models/SystemFile.cs | 33 ++++++++++--------- 6 files changed, 49 insertions(+), 45 deletions(-) diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs index 4aa9112..76bd4ca 100644 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -14,7 +14,7 @@ namespace Parallel.Cli.Commands public class DecryptCommand : Command { private readonly Argument _sourceArg = new("path", "The source path of files to zip."); - private readonly Option _configOpt = new(["--config", "-c"], "The profile configuration to use."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private IDatabase? _database; private Stopwatch _sw = new Stopwatch(); @@ -26,15 +26,15 @@ public DecryptCommand() : base("decrypt", "Decrypts a file or directory.") this.AddArgument(_sourceArg); this.SetHandler(async (path, config) => { - ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config); - if (profile == null) + VaultConfig? vault = VaultConfig.Load(Program.Settings, config); + if (vault == null) { - CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow); + CommandLine.WriteLine("No active vault was found!", ConsoleColor.Yellow); return; } - _database = DatabaseConnection.CreateNew(profile); - string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); + _database = DatabaseConnection.CreateNew(vault); + string masterKey = vault.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); if (PathBuilder.IsDirectory(path)) { await DecryptDirectoryAsync(path, masterKey); diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs index aea212d..36ee33f 100644 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -7,6 +7,7 @@ using Parallel.Core.Database; using Parallel.Core.IO; using Parallel.Core.Models; +using Parallel.Core.Security; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -15,7 +16,7 @@ namespace Parallel.Cli.Commands public class EncryptCommand : Command { private readonly Argument _sourceArg = new("path", "The source path to encrypt."); - private readonly Option _configOpt = new(["--config", "-c"], "The profile configuration to use."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private IDatabase? _database; private Stopwatch _sw = new Stopwatch(); @@ -28,15 +29,15 @@ public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") this.SetHandler(async (path, config) => { _sw = Stopwatch.StartNew(); - ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config); - if (profile == null) + VaultConfig? vault = VaultConfig.Load(Program.Settings, config); + if (vault == null) { - CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow); + CommandLine.WriteLine("No active vault was found!", ConsoleColor.Yellow); return; } - _database = DatabaseConnection.CreateNew(profile); - string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); + _database = DatabaseConnection.CreateNew(vault); + string masterKey = vault.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); if (PathBuilder.IsDirectory(path)) { await EncryptDirectoryAsync(path, masterKey); diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 738fab1..db9a423 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -43,8 +43,8 @@ public async Task InitializeAsync() File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`profile` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, PRIMARY KEY(`profile`, `id`));"); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`profile` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`profile`, `timestamp`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, `checksum` BLOB, PRIMARY KEY(`vault`, `id`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`vault` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`vault`, `timestamp`));"); } #endregion @@ -55,7 +55,7 @@ public async Task InitializeAsync() public async Task AddFileAsync(SystemFile file) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO files (profile, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);"; + string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);"; return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV }) > 0; } @@ -63,7 +63,7 @@ public async Task AddFileAsync(SystemFile file) public async Task> GetFilesAsync(string path, bool deleted) { using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND deleted = {deleted} ORDER BY lastupdate DESC"; + string sql = $"SELECT * FROM files WHERE vault = \"{ProfileId}\" AND deleted = {deleted} ORDER BY lastupdate DESC"; return await connection.QueryAsync(sql); } @@ -71,7 +71,7 @@ public async Task> GetFilesAsync(string path, bool delet public async Task GetFileAsync(string path) { using IDbConnection connection = CreateConnection(); - string sql = $"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + string sql = $"SELECT * FROM files WHERE vault = \"{ProfileId}\" AND localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; return await connection.QuerySingleOrDefaultAsync(sql); } @@ -83,7 +83,7 @@ public async Task> GetFilesAsync(string path, bool delet public async Task AddHistoryAsync(string path, HistoryType type) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO history (profile, timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; + string sql = @"INSERT OR REPLACE INTO history (vault, timestamp, name, path, type) VALUES(@ProfileId, @Timestamp, @Name, @Path, @Type);"; return await connection.ExecuteAsync(sql, new { ProfileId, Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; } diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs index b2c8998..c65f6cd 100644 --- a/Parallel.Core/Database/DatabaseConnection.cs +++ b/Parallel.Core/Database/DatabaseConnection.cs @@ -17,15 +17,15 @@ public enum DatabaseProvider /// public class DatabaseConnection { - public static IDatabase CreateNew(ProfileConfig profile) + public static IDatabase CreateNew(VaultConfig vault) { - switch(profile.Database.Provider) + switch(vault.Database.Provider) { default: return null; case DatabaseProvider.Local: - IDatabase db = new SqliteContext(profile.Database, profile.Id); - if (!File.Exists(profile.Database.Address)) db.InitializeAsync(); + IDatabase db = new SqliteContext(vault.Database, vault.Id); + if (!File.Exists(vault.Database.Address)) db.InitializeAsync(); return db; } } diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs index ccab8da..dfe4e23 100644 --- a/Parallel.Core/IO/Recovery/RecoveryManager.cs +++ b/Parallel.Core/IO/Recovery/RecoveryManager.cs @@ -17,7 +17,7 @@ public class RecoveryManager public IDatabase Database { get; set; } public IFileSystem FileSystem { get; set; } - public ProfileConfig Profile { get; set; } + public VaultConfig Vault { get; set; } public string MachineName { get; } = Environment.MachineName; public string RootFolder { get; set; } @@ -25,21 +25,21 @@ public class RecoveryManager /// Initializes a new instance of the class. /// /// - public RecoveryManager(ProfileConfig profile) + public RecoveryManager(VaultConfig vault) { - Profile = profile; - Database = DatabaseConnection.CreateNew(profile); - FileSystem = FileSystemManager.CreateNew(profile.FileSystem); + Vault = vault; + Database = DatabaseConnection.CreateNew(vault); + FileSystem = FileSystemManager.CreateNew(vault.FileSystem); } public bool Initialize() { try { - Database = DatabaseConnection.CreateNew(Profile); + Database = DatabaseConnection.CreateNew(Vault); bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Profile.IgnoreDirectories.Add(Profile.FileSystem.RootDirectory); - if (Profile != null) Profile.SaveToFile(); + Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); + if (Vault != null) Vault.SaveToFile(); return fsInit; } catch (Exception ex) diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index aa4ccdb..a18dfed 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -3,6 +3,7 @@ using System.Data; using Parallel.Core.Data; using Parallel.Core.Diagnostics; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.Models @@ -80,12 +81,17 @@ public class SystemFile /// /// The salt used to encrypt the file. /// - public byte[] Salt { get; set; } = Array.Empty(); + public byte[] Salt { get; set; } = []; /// /// The initialization vector used to encrypt the file. /// - public byte[] IV { get; set; } = Array.Empty(); + public byte[] IV { get; set; } = []; + + /// + /// The checksum used to check if the file has changed. + /// + public byte[] CheckSum { get; set; } = []; /// @@ -99,27 +105,23 @@ public SystemFile(string path) LocalPath = fileInfo.FullName; LocalSize = fileInfo.Length; RemoteSize = fileInfo.Length; - Type = FileTypes.GetFileCategory(Path.GetExtension(fileInfo.Name)); LastWrite = new UnixTime(fileInfo.LastWriteTime); LastUpdate = UnixTime.Now; + Type = FileTypes.GetFileCategory(Path.GetExtension(fileInfo.Name)); + Hidden = fileInfo.Attributes.HasFlag(FileAttributes.Hidden); + ReadOnly = fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly); Deleted = !fileInfo.Exists; - - if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden)) - { - Hidden = true; - } - - if (fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly)) - { - ReadOnly = true; - } + Encrypted = false; + Salt = HashGenerator.RandomBytes(16); + IV = HashGenerator.RandomBytes(16); + CheckSum = HashGenerator.CheckSum(path); } /// /// Initializes a new instance of the class. /// /// - public SystemFile(string profile, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, byte[] salt, byte[] iv) + public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, byte[] salt, byte[] iv, byte[] checksum) { Id = id; Name = name; @@ -129,13 +131,13 @@ public SystemFile(string profile, string id, string name, string localpath, stri LastUpdate = UnixTime.FromMilliseconds(lastupdate); LocalSize = localsize; RemoteSize = remotesize; - //Type = type; Hidden = Converter.ToBool(hidden); ReadOnly = Converter.ToBool(readOnly); Deleted = Converter.ToBool(deleted); Encrypted = Converter.ToBool(encrypted); Salt = salt; IV = iv; + CheckSum = checksum; } public bool Equals(SystemFile value) @@ -155,6 +157,7 @@ public bool Equals(SystemFile value) value?.Encrypted != null ? this.Encrypted.Equals(value.Encrypted) : (bool?)null, this?.Salt != null && value?.Salt != null ? this.Salt.SequenceEqual(value.Salt) : (bool?)null, this?.IV != null && value?.IV != null ? this.IV.SequenceEqual(value.IV) : (bool?)null, + this?.CheckSum != null && value?.CheckSum != null ? this.CheckSum.SequenceEqual(value.CheckSum) : (bool?)null, ]; return results.All(b => b != null && (bool)b); From 86780c51ae4f633ccd80fdeeb980dc0c7d67cde8 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:30:16 -0500 Subject: [PATCH 07/14] Added logging --- Parallel.Cli/Utils/CommandLine.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index ac5bdf6..bc4f8ae 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -73,11 +73,28 @@ public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) { + Log.Information(value.ToString()!); Console.ForegroundColor = color; Console.WriteLine($"> {value}"); Console.ResetColor(); } + public static void WriteWarning(object value) + { + Log.Warning(value.ToString()!); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"> {value}"); + Console.ResetColor(); + } + + public static void WriteError(object value) + { + Log.Error(value.ToString()!); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"> {value}"); + Console.ResetColor(); + } + public static void ProgressBar(double part, double total, TimeSpan elapsed, ConsoleColor color = ConsoleColor.Gray) { double percent = part / total; From ac213fcc3d2a2e117333f1a4e83f5337ee7fb262 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 03:30:49 -0500 Subject: [PATCH 08/14] Moved to security folder --- Parallel.Core/Security/Encryption.cs | 1 + Parallel.Core/Security/HashGenerator.cs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Parallel.Core/Security/Encryption.cs b/Parallel.Core/Security/Encryption.cs index 029f6fe..3878aba 100644 --- a/Parallel.Core/Security/Encryption.cs +++ b/Parallel.Core/Security/Encryption.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using Parallel.Core.Models; +using Parallel.Core.Security; namespace Parallel.Core.Utils { diff --git a/Parallel.Core/Security/HashGenerator.cs b/Parallel.Core/Security/HashGenerator.cs index 43614f4..eccf200 100644 --- a/Parallel.Core/Security/HashGenerator.cs +++ b/Parallel.Core/Security/HashGenerator.cs @@ -3,13 +3,18 @@ using System.Security.Cryptography; using System.Text; -namespace Parallel.Core.Utils +namespace Parallel.Core.Security { /// /// Provides functionality for generating random hashes. This class cannot be inherited. /// public static class HashGenerator { + /// + /// Generates a random series of bytes. + /// + /// + /// public static byte[] RandomBytes(int length) { byte[] bytes = new byte[length]; @@ -76,5 +81,18 @@ public static string CreateSHA256(string value) ArgumentException.ThrowIfNullOrEmpty(value); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLower(); } + + /// + /// + /// + /// + /// + public static byte[] CheckSum(string path) + { + if(!File.Exists(path)) return []; + using FileStream fs = File.OpenRead(path); + using SHA256 sha256 = SHA256.Create(); + return sha256.ComputeHash(fs); + } } } \ No newline at end of file From 8076a3623f82262a348c9f49bc594860c9cb19b2 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 05:28:31 -0500 Subject: [PATCH 09/14] Now uses checksums to validate file changes --- Parallel.Cli/Commands/PushCommand.cs | 138 ++++++++++++++++++ Parallel.Cli/Commands/VaultsCommand.cs | 2 +- Parallel.Cli/Program.cs | 1 - Parallel.Cli/Utils/CommandLine.cs | 4 +- Parallel.Cli/Utils/ProgressReport.cs | 1 + .../Database/Contexts/SqliteContext.cs | 4 +- .../IO/FileSystem/DotNetFileSystem.cs | 14 +- .../IO/FileSystem/FileSystemManager.cs | 10 +- Parallel.Core/IO/FileSystem/SftpFileSystem.cs | 9 +- Parallel.Core/IO/PathBuilder.cs | 16 +- Parallel.Core/IO/Recovery/RecoveryManager.cs | 2 +- Parallel.Core/IO/Scanning/FileScanner.cs | 92 +++++------- Parallel.Core/IO/Syncing/BaseSyncManager.cs | 8 +- Parallel.Core/IO/Syncing/FileSyncManager.cs | 4 +- Parallel.Core/Settings/ParallelSettings.cs | 15 ++ 15 files changed, 229 insertions(+), 91 deletions(-) create mode 100644 Parallel.Cli/Commands/PushCommand.cs diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs new file mode 100644 index 0000000..bfec12c --- /dev/null +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -0,0 +1,138 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.IO; +using Parallel.Core.IO.Backup; +using Parallel.Core.IO.Scanning; +using Parallel.Core.Models; + +namespace Parallel.Cli.Commands +{ + public class PushCommand : Command + { + private Command addCmd = new("add", "Adds a new directory to the backup list."); + private Command listCmd = new("list", "Shows all directories in the backup list."); + private Command removeCmd = new("remove", "Removes a directory from the backup list."); + + private readonly Option _sourceArg = new(["--path", "-p"], "The source path to backup."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); + private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); + + public PushCommand() : base("push", "Pushes files to ") + { + this.AddOption(_sourceArg); + this.AddOption(_configOpt); + this.AddOption(_verboseOpt); + this.SetHandler(async (path, config, verbose) => + { + if (string.IsNullOrEmpty(path)) + { + await SyncSystemAsync(); + } + else + { + if (PathBuilder.IsDirectory(path)) + { + await SyncDirectoryAsync(path); + } + else if (PathBuilder.IsFile(path)) + { + await SyncFileAsync(path); + } + } + }, _sourceArg, _configOpt, _verboseOpt); + } + + private async Task SyncSystemAsync() + { + throw new NotImplementedException(); + } + + private async Task SyncDirectoryAsync(string path) + { + await Program.Settings.ForEachVaultAsync(async (vault) => + { + ISyncManager sync = SyncManager.CreateNew(vault); + if (!sync.Initialize()) + { + CommandLine.WriteError($"Failed to connect to vault '{vault.Name}'!"); + return; + } + + string[] backupFolders = vault.BackupDirectories.ToArray(); + if (!backupFolders.Any(path.StartsWith)) + { + CommandLine.WriteWarning($"The provided folder is not set to be backed up!"); + return; + } + + string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); + FileScanner scanner = new FileScanner(sync); + + // Checks if the file can be backed up. + if (FileScanner.IsIgnored(path, ignoredFolders)) + { + CommandLine.WriteWarning($"The provided folder is set to be ignored!"); + return; + } + + CommandLine.WriteLine($"Scanning for file changes in {path}...", ConsoleColor.DarkGray); + SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); + int successFiles = files.Length; + if (successFiles == 0) + { + CommandLine.WriteLine($"The provided directory is already up to date.", ConsoleColor.Green); + return; + } + + CommandLine.WriteLine($"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + await sync.PushFilesAsync(files, new ProgressReport()); + CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files.", ConsoleColor.Green); + }); + } + + private async Task SyncFileAsync(string path) + { + await Program.Settings.ForEachVaultAsync(async (vault) => + { + ISyncManager sync = SyncManager.CreateNew(vault); + if (!sync.Initialize()) + { + CommandLine.WriteError("Failed to connect to backup file system!"); + return; + } + + string[] backupFolders = vault.BackupDirectories.ToArray(); + if (!backupFolders.Any(path.StartsWith)) + { + CommandLine.WriteWarning($"The provided file is not set to be backed up!"); + return; + } + + string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); + FileScanner scanner = new FileScanner(sync); + + // Checks if the file can be backed up. + if (FileScanner.IsIgnored(path, ignoredFolders)) + { + CommandLine.WriteWarning($"The provided folder is set to be ignored!"); + return; + } + + SystemFile localFile = new SystemFile(path); + SystemFile? remoteFile = await sync.Database.GetFileAsync(localFile.LocalPath); + + if (!FileScanner.HasChanged(localFile, remoteFile)) + { + CommandLine.WriteLine($"The provided file is already up to date.", ConsoleColor.Green); + return; + } + + CommandLine.WriteLine($"Pushing: {localFile.LocalPath}", ConsoleColor.DarkGray); + await sync.PushFilesAsync([localFile], new ProgressReport()); + CommandLine.WriteLine($"Successfully pushed: {localFile.LocalPath}", ConsoleColor.Green); + }); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs index d6b1492..6413342 100644 --- a/Parallel.Cli/Commands/VaultsCommand.cs +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -73,7 +73,7 @@ public VaultsCommand() : base("vaults", "View or edit the vaults.") fsc.Encrypt = CommandLine.ReadBool("Encrypt files? (y/n)", false); fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); - string profileName = CommandLine.ReadString("Profile Name"); + string? profileName = CommandLine.ReadString("Profile Name"); VaultConfig vault = new VaultConfig(profileName, dbc, fsc); vault.SaveToFile(); diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index b6ed7a0..3c54b91 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -16,7 +16,6 @@ public static async Task Main(string[] args) Settings = ParallelSettings.Load(); string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.File(logFile).CreateLogger(); - //Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); Log.Information($"{assembly.Name} [Version {assembly.Version}]"); diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index bc4f8ae..e2687d0 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -7,12 +7,12 @@ namespace Parallel.Cli.Utils { public class CommandLine { - public static string? ReadString(object value, ConsoleColor color = ConsoleColor.Gray) + public static string ReadString(object value, ConsoleColor color = ConsoleColor.Gray) { Console.ForegroundColor = color; Console.Write($"> {value}: "); Console.ResetColor(); - return Console.ReadLine(); + return Console.ReadLine() ?? string.Empty; } public static bool ReadBool(object value, bool defaultValue, ConsoleColor color = ConsoleColor.Gray) diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index a5e1d49..eacc676 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -2,6 +2,7 @@ using Parallel.Core.Diagnostics; using Parallel.Core.Models; +using Parallel.Core.Settings; namespace Parallel.Cli.Utils { diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index db9a423..28017ef 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -55,8 +55,8 @@ public async Task InitializeAsync() public async Task AddFileAsync(SystemFile file) { using IDbConnection connection = CreateConnection(); - string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);"; - return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV }) > 0; + string sql = @"INSERT OR REPLACE INTO files (vault, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv, checksum) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV, @CheckSum);"; + return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV, file.CheckSum }) > 0; } /// diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index 9268564..3a6e389 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -17,15 +17,15 @@ namespace Parallel.Core.IO.FileSystem /// public class DotNetFileSystem : IFileSystem { - private readonly FileSystemCredentials _credentials; + private readonly VaultConfig _vault; /// /// Represents an for interacting with physical machine hardware. /// - /// The credentials to log in with. - public DotNetFileSystem(FileSystemCredentials credentials) + /// The vault to use. + public DotNetFileSystem(VaultConfig vault) { - _credentials = credentials; + _vault = vault; } /// @@ -83,7 +83,7 @@ public Task GetDirectoryNameAsync(string path) public Task> GetFilesAsync() { Dictionary files = new Dictionary(); - foreach (string file in Directory.GetFiles(PathBuilder.RootDirectory(_credentials), "*.gz", SearchOption.AllDirectories)) + foreach (string file in Directory.GetFiles(PathBuilder.RootDirectory(_vault), "*.gz", SearchOption.AllDirectories)) { FileInfo fi = new(file); files.Add(fi.FullName, new SystemFile(file) @@ -132,7 +132,7 @@ public Task GetFileAsync(string path) public Task PingAsync() { Stopwatch sw = Stopwatch.StartNew(); - if (!Directory.Exists(PathBuilder.RootDirectory(_credentials))) return Task.FromResult(-1); + if (!Directory.Exists(PathBuilder.RootDirectory(_vault))) return Task.FromResult(-1); return Task.FromResult(sw.ElapsedMilliseconds); } @@ -144,7 +144,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres { Stopwatch sw = new Stopwatch(); SystemFile file = files[i]; - file.RemotePath = PathBuilder.Remote(file.LocalPath, _credentials); + file.RemotePath = PathBuilder.Remote(file.LocalPath, _vault); progress.Report(ProgressOperation.Uploading, file, i, files.Length); if (File.Exists(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); diff --git a/Parallel.Core/IO/FileSystem/FileSystemManager.cs b/Parallel.Core/IO/FileSystem/FileSystemManager.cs index 97fa7b5..1e93b4d 100644 --- a/Parallel.Core/IO/FileSystem/FileSystemManager.cs +++ b/Parallel.Core/IO/FileSystem/FileSystemManager.cs @@ -33,13 +33,13 @@ public static class FileSystemManager /// /// Creates a new file system association. /// - /// The credentials needed for the associated file system. - public static IFileSystem CreateNew(FileSystemCredentials credentials) + /// The vault needed for the associated file system. + public static IFileSystem CreateNew(VaultConfig vault) { - return credentials?.Service switch + return vault.FileSystem.Service switch { - FileService.Local => new DotNetFileSystem(credentials), - FileService.Remote => new SftpFileSystem(credentials), + FileService.Local => new DotNetFileSystem(vault), + FileService.Remote => new SftpFileSystem(vault), //FileService.Cloud => new AmazonS3FileSystem(credentials), _ => null }; diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs index 58ec6cd..3a6a503 100644 --- a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -18,15 +18,16 @@ namespace Parallel.Core.IO.FileSystem public class SftpFileSystem : IFileSystem { private readonly ConnectionInfo _connectionInfo; + private readonly VaultConfig _vault; /// /// Represents an for interacting with an SSH server. /// - /// The credentials to log in with. - public SftpFileSystem(FileSystemCredentials credentials) + /// The credentials to log in with. + public SftpFileSystem(VaultConfig vault) { - Console.WriteLine(JObject.FromObject(credentials)); - _connectionInfo = new ConnectionInfo(credentials.Address, credentials.Username, new PasswordAuthenticationMethod(credentials.Username, Encryption.Decode(credentials.Password))); + _connectionInfo = new ConnectionInfo(vault.FileSystem.Address, vault.FileSystem.Username, new PasswordAuthenticationMethod(vault.FileSystem.Username, Encryption.Decode(vault.FileSystem.Password))); + _vault = vault; } /// diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs index 6833235..99fe668 100644 --- a/Parallel.Core/IO/PathBuilder.cs +++ b/Parallel.Core/IO/PathBuilder.cs @@ -71,15 +71,15 @@ public static string Local(string path, FileSystemCredentials credentials) return main.Replace(@"\", "/"); } - public static string RootDirectory(FileSystemCredentials credentials) + public static string RootDirectory(VaultConfig vault) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName); + string root = Path.Combine(vault.FileSystem.RootDirectory, "Parallel", vault.Id); Log.Debug($"Root directory: {root}"); - return credentials.Service switch + return vault.FileSystem.Service switch { FileService.Local => root, FileService.Remote => root.Replace('\\', '/'), - _ => null + _ => string.Empty }; } @@ -89,14 +89,14 @@ public static string RootDirectory(FileSystemCredentials credentials) /// /// /// - public static string Remote(string path, FileSystemCredentials credentials) + public static string Remote(string path, VaultConfig vault) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName, path.Replace(":", string.Empty)) + ".gz"; - return credentials.Service switch + string root = Path.Combine(vault.FileSystem.RootDirectory, "Parallel", vault.Id, "Files", path.Replace(":", string.Empty)) + ".gz"; + return vault.FileSystem.Service switch { FileService.Local => root, FileService.Remote => root.Replace('\\', '/'), - _ => null + _ => string.Empty }; } diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs index dfe4e23..43f1ad7 100644 --- a/Parallel.Core/IO/Recovery/RecoveryManager.cs +++ b/Parallel.Core/IO/Recovery/RecoveryManager.cs @@ -29,7 +29,7 @@ public RecoveryManager(VaultConfig vault) { Vault = vault; Database = DatabaseConnection.CreateNew(vault); - FileSystem = FileSystemManager.CreateNew(vault.FileSystem); + FileSystem = FileSystemManager.CreateNew(vault); } public bool Initialize() diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 90374fc..013ae05 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -2,6 +2,8 @@ using System.Data; using System.Diagnostics; +using System.Text; +using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.Backup; using Parallel.Core.Models; @@ -16,19 +18,19 @@ namespace Parallel.Core.IO.Scanning /// public class FileScanner { - private readonly ProfileConfig _profile; + private readonly VaultConfig _vault; private readonly IDatabase _db; - public FileScanner(ProfileConfig profile, IDatabase database) + public FileScanner(VaultConfig vault, IDatabase database) { - _profile = profile; + _vault = vault; _db = database; } - public FileScanner(IBackupManager backup) + public FileScanner(ISyncManager sync) { - _profile = backup.Profile; - _db = backup.Database; + _vault = sync.Vault; + _db = sync.Database; } /*/// @@ -58,77 +60,57 @@ public async Task GetFileChangesAsync(string path, string[] ignore { if (!Directory.Exists(path)) return Array.Empty(); - List scannedFiles = new(); - List localFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToList(); - List remoteFiles = (await _db.GetFilesAsync(path, false)).ToList(); - Stopwatch sw = Stopwatch.StartNew(); - - foreach (SystemFile rsf in remoteFiles.ToArray()) + List scannedFiles = new List(); + HashSet localFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToHashSet(); + IEnumerable remoteFiles = await _db.GetFilesAsync(path, false); + foreach (SystemFile remoteFile in remoteFiles) { - // Checks if the local file has a valid path and is part of a backup folder. - if (rsf.LocalPath != null && rsf.LocalPath.Contains(path)) + if (File.Exists(remoteFile.LocalPath) && remoteFile.RemotePath != null) { - // Checks if a LocalFile exists on the current file system. - if (File.Exists(rsf.LocalPath) && rsf.RemotePath != null) + SystemFile localFile = new SystemFile(remoteFile.LocalPath); + if (IsIgnored(localFile.LocalPath, ignoreFolders)) { - SystemFile lfi = new(rsf.LocalPath); - if (IsIgnored(lfi.LocalPath, ignoreFolders)) - { - Log.Debug($"Is ignored -> {lfi.LocalPath}"); - - lfi.Deleted = true; - scannedFiles.Add(lfi); - } - - if (rsf.LastWrite.TotalMilliseconds < lfi.LastWrite.TotalMilliseconds) - { - Log.Debug($"Changed -> {lfi.LocalPath}"); - - // Changed file - rsf.Deleted = false; - scannedFiles.Add(lfi); - } - - localFiles.Remove(lfi.LocalPath); + Log.Debug($"Ignored -> {localFile.LocalPath}"); + localFile.Deleted = true; + scannedFiles.Add(localFile); } - else + else if (HasChanged(localFile, remoteFile)) { - // Adds deleted files - Log.Debug($"Deleted -> {rsf.LocalPath}"); - - rsf.Deleted = true; - scannedFiles.Add(rsf); + Log.Debug($"Changed -> {localFile.LocalPath}"); + scannedFiles.Add(localFile); } + + localFiles.Remove(localFile.LocalPath); } else { - // Deletes ignored files - Log.Debug($"No contains Ignored -> {rsf.LocalPath}"); - - rsf.Deleted = true; - scannedFiles.Add(rsf); + Log.Debug($"Deleted -> {remoteFile.LocalPath}"); + remoteFile.Deleted = true; + scannedFiles.Add(remoteFile); } } Log.Debug($"{localFiles.Count} files are untracked! Adding..."); - if (localFiles.Count > 0) + foreach (var file in localFiles) { - foreach (string file in localFiles.ToArray()) + if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) { - if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) - { - Log.Debug($"Created -> {file}"); - scannedFiles.Add(new SystemFile(file)); - localFiles.Remove(file); - } + Log.Debug($"Created -> {file}"); + scannedFiles.Add(new SystemFile(file)); } } Log.Debug($"{localFiles.Count} files remaining."); - Log.Information($"Found {localFiles.Count.ToString("N0")} files in '{path}'. ({sw.ElapsedMilliseconds}ms)"); + Log.Information($"Found {scannedFiles.Count:N0} changes in '{path}'"); return scannedFiles.ToArray(); } + public static bool HasChanged(SystemFile localFile, SystemFile? remoteFile) + { + return remoteFile == null || (localFile.LastWrite.TotalMilliseconds > remoteFile.LastWrite.TotalMilliseconds && !localFile.CheckSum.SequenceEqual(remoteFile.CheckSum)); + } + + /// /// Gets the total size, in bytes, of a directory. /// diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs index 9d6933d..c2f3362 100644 --- a/Parallel.Core/IO/Syncing/BaseSyncManager.cs +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -29,7 +29,7 @@ public abstract class BaseSyncManager : ISyncManager /// public BaseSyncManager(VaultConfig vault) { - FileSystem = FileSystemManager.CreateNew(vault.FileSystem); + FileSystem = FileSystemManager.CreateNew(vault); Vault = vault; } @@ -39,10 +39,10 @@ public virtual bool Initialize() try { Database = DatabaseConnection.CreateNew(Vault); - bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; + FileSystem.CreateDirectoryAsync(PathBuilder.RootDirectory(Vault)); Vault.IgnoreDirectories.Add(Vault.FileSystem.RootDirectory); - if (Vault != null) Vault.SaveToFile(); - return fsInit; + Vault.SaveToFile(); + return FileSystem.PingAsync().Result >= 0; } catch (Exception ex) { diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs index 806f442..57ecf45 100644 --- a/Parallel.Core/IO/Syncing/FileSyncManager.cs +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -32,6 +32,8 @@ public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); Log.Information($"Backing up {backupFiles.Length} files..."); await FileSystem.UploadFilesAsync(backupFiles, progress); + + Console.WriteLine($"Successfully pushed {backupFiles.Length} files.", ConsoleColor.Green); for (int i = 0; i < files.Length; i++) { SystemFile file = files.ElementAt(i); @@ -67,7 +69,7 @@ public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter { SystemFile file = files[i]; Log.Information($"Restoring file: {file.LocalPath}..."); - file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault.FileSystem); + file.RemotePath = PathBuilder.Remote(file.LocalPath, Vault); } } } diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index bc3a562..bb9c834 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -79,5 +79,20 @@ public void ForEachVault(Action action) if (vault != null) action(vault); } } + + public async Task ForEachVaultAsync(Func actionAsync) + { + List tasks = new(); + foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) + { + VaultConfig? vault = VaultConfig.Load(path); + if (vault != null) + { + tasks.Add(actionAsync(vault)); + } + } + + await Task.WhenAll(tasks); + } } } \ No newline at end of file From dea9adc33d3db069cf1c2cf8ebd039e0c9c463e8 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Tue, 26 Aug 2025 05:47:24 -0500 Subject: [PATCH 10/14] Changed byte arrays to strings for ease with database querying --- Parallel.Cli/Commands/DecryptCommand.cs | 1 + Parallel.Cli/Commands/EncryptCommand.cs | 4 +-- .../Database/Contexts/SqliteContext.cs | 2 +- Parallel.Core/Models/SystemFile.cs | 30 ++++++++++++++----- Parallel.Core/Security/Encryption.cs | 12 ++++---- Parallel.Core/Security/HashGenerator.cs | 6 ++-- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs index 76bd4ca..b451fba 100644 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -2,6 +2,7 @@ using System.CommandLine; using System.Diagnostics; +using System.Text; using Parallel.Cli.Utils; using Parallel.Core.Database; using Parallel.Core.IO; diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs index 36ee33f..f76caf0 100644 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -88,8 +88,8 @@ private async Task EncryptFileAsync(string path, string masterKey) await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate)) { - systemFile.Salt = HashGenerator.RandomBytes(16); - systemFile.IV = HashGenerator.RandomBytes(16); + systemFile.Salt = HashGenerator.GenerateHash(16); + systemFile.IV = HashGenerator.GenerateHash(16); systemFile.Encrypted = true; Encryption.EncryptStream(openFile, createFile, masterKey, systemFile.LastWrite, systemFile.Salt, systemFile.IV); diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 28017ef..a4c2eb4 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -43,7 +43,7 @@ public async Task InitializeAsync() File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); using IDbConnection connection = CreateConnection(); - await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, `checksum` BLOB, PRIMARY KEY(`vault`, `id`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`vault` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` TEXT, `iv` TEXT, `checksum` TEXT, PRIMARY KEY(`vault`, `id`));"); await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`vault` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`vault`, `timestamp`));"); } diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index a18dfed..514abbd 100644 --- a/Parallel.Core/Models/SystemFile.cs +++ b/Parallel.Core/Models/SystemFile.cs @@ -81,17 +81,17 @@ public class SystemFile /// /// The salt used to encrypt the file. /// - public byte[] Salt { get; set; } = []; + public string Salt { get; set; } /// /// The initialization vector used to encrypt the file. /// - public byte[] IV { get; set; } = []; + public string IV { get; set; } /// /// The checksum used to check if the file has changed. /// - public byte[] CheckSum { get; set; } = []; + public string? CheckSum { get; set; } /// @@ -112,16 +112,32 @@ public SystemFile(string path) ReadOnly = fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly); Deleted = !fileInfo.Exists; Encrypted = false; - Salt = HashGenerator.RandomBytes(16); - IV = HashGenerator.RandomBytes(16); + Salt = HashGenerator.GenerateHash(16); + IV = HashGenerator.GenerateHash(16); CheckSum = HashGenerator.CheckSum(path); } /// /// Initializes a new instance of the class. /// - /// - public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, byte[] salt, byte[] iv, byte[] checksum) + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public SystemFile(string vault, string id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, long encrypted, string salt, string iv, string checksum) { Id = id; Name = name; diff --git a/Parallel.Core/Security/Encryption.cs b/Parallel.Core/Security/Encryption.cs index 3878aba..3a7fc45 100644 --- a/Parallel.Core/Security/Encryption.cs +++ b/Parallel.Core/Security/Encryption.cs @@ -47,14 +47,14 @@ public static string Decode(string value) /// /// /// - public static void EncryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, byte[] salt, byte[] iv) + public static void EncryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, string salt, string iv) { input.Position = 0; - byte[] derivedKey = HashGenerator.HKDF(masterKey, salt, timestamp.ToISOString(), 32); + byte[] derivedKey = HashGenerator.HKDF(masterKey, Encoding.ASCII.GetBytes(salt), timestamp.ToISOString(), 32); using (Aes aes = Aes.Create()) { aes.Key = derivedKey; - aes.IV = iv; + aes.IV = Encoding.UTF8.GetBytes(iv); aes.Mode = CipherMode.CBC; using (CryptoStream cryptoStream = new CryptoStream(output, aes.CreateEncryptor(), CryptoStreamMode.Write)) { @@ -70,14 +70,14 @@ public static void EncryptStream(Stream input, Stream output, string masterKey, /// /// /// - public static void DecryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, byte[] salt, byte[] iv) + public static void DecryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, string salt, string iv) { input.Position = 0; - byte[] derivedKey = HashGenerator.HKDF(masterKey, salt, timestamp.ToISOString(), 32); + byte[] derivedKey = HashGenerator.HKDF(masterKey, Encoding.ASCII.GetBytes(salt), timestamp.ToISOString(), 32); using (Aes aes = Aes.Create()) { aes.Key = derivedKey; - aes.IV = iv; + aes.IV = Encoding.ASCII.GetBytes(iv); aes.Mode = CipherMode.CBC; using (CryptoStream cryptoStream = new CryptoStream(input, aes.CreateDecryptor(), CryptoStreamMode.Read)) { diff --git a/Parallel.Core/Security/HashGenerator.cs b/Parallel.Core/Security/HashGenerator.cs index eccf200..71fa07f 100644 --- a/Parallel.Core/Security/HashGenerator.cs +++ b/Parallel.Core/Security/HashGenerator.cs @@ -87,12 +87,12 @@ public static string CreateSHA256(string value) /// /// /// - public static byte[] CheckSum(string path) + public static string? CheckSum(string path) { - if(!File.Exists(path)) return []; + if (!File.Exists(path)) return null; using FileStream fs = File.OpenRead(path); using SHA256 sha256 = SHA256.Create(); - return sha256.ComputeHash(fs); + return Convert.ToHexString(sha256.ComputeHash(fs)).ToLowerInvariant(); } } } \ No newline at end of file From 73c9f89dbb99dc4d56522aa43461b9f7400b6c16 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Fri, 29 Aug 2025 01:19:27 -0500 Subject: [PATCH 11/14] Various async changes --- Parallel.Cli/Commands/HistoryCommand.cs | 139 ++++++++++++++++++ Parallel.Cli/Commands/PullCommand.cs | 9 ++ Parallel.Cli/Commands/PushCommand.cs | 11 +- .../Database/Contexts/SqliteContext.cs | 10 ++ Parallel.Core/Database/DatabaseConnection.cs | 4 +- Parallel.Core/Database/IDatabase.cs | 4 + .../IO/FileSystem/DotNetFileSystem.cs | 6 +- Parallel.Core/Models/HistoryEvent.cs | 8 +- Parallel.Core/Settings/DatabaseCredentials.cs | 4 +- .../Settings/FileSystemCredentials.cs | 6 +- 10 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 Parallel.Cli/Commands/HistoryCommand.cs create mode 100644 Parallel.Cli/Commands/PullCommand.cs diff --git a/Parallel.Cli/Commands/HistoryCommand.cs b/Parallel.Cli/Commands/HistoryCommand.cs new file mode 100644 index 0000000..eef71c2 --- /dev/null +++ b/Parallel.Cli/Commands/HistoryCommand.cs @@ -0,0 +1,139 @@ +// Copyright 2025 Entex Interactive, LLC + +using System.CommandLine; +using System.Data; +using Parallel.Cli.Utils; +using Parallel.Core.Database; +using Parallel.Core.IO; +using Parallel.Core.IO.Backup; +using Parallel.Core.Models; +using Parallel.Core.Settings; +using Parallel.Core.Utils; +using Formatter = Parallel.Cli.Utils.Formatter; + +namespace Parallel.Cli.Commands +{ + public class HistoryCommand : Command + { + private const int Limit = 25; + + private Command _pullCmd = new("pull", "Shows the history related to pulling files from vaults."); + private Command _pushCmd = new("push", "Shows the history related to pushing files from vaults."); + private Command _deleteCmd = new("archive", "Shows the history related to file deletions."); + private Command _cleanCmd = new("cleaned", "Shows the history related to file cleaning."); + private Command _cloneCmd = new("cloned", "Shows the history related to file cloning."); + private Command _pruneCmd = new("pruned", "Shows the history related to file pruning."); + + private Option _sourceOpt = new(["--path", "-p"], "The source path."); + private Option _vaultOpt = new(["--vault", "-v"], "The vault to use."); + private Option _limitOpt = new(["--limit", "-l"], "The number of entries to show."); + + public HistoryCommand() : base("history", "Shows the history of files related to the archive.") + { + this.AddOption(_sourceOpt); + this.AddOption(_vaultOpt); + this.AddOption(_limitOpt); + this.AddCommand(_pullCmd); + this.AddCommand(_pushCmd); + this.AddCommand(_deleteCmd); + this.AddCommand(_cleanCmd); + this.AddCommand(_cloneCmd); + this.AddCommand(_pruneCmd); + this.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _pushCmd.AddOption(_sourceOpt); + _pushCmd.AddOption(_vaultOpt); + _pushCmd.AddOption(_limitOpt); + _pushCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving backup information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Pushed, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _deleteCmd.AddOption(_sourceOpt); + _deleteCmd.AddOption(_vaultOpt); + _deleteCmd.AddOption(_limitOpt); + _deleteCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving archive information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Archived, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _cleanCmd.AddOption(_sourceOpt); + _cleanCmd.AddOption(_vaultOpt); + _cleanCmd.AddOption(_limitOpt); + _cleanCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving clean information...", ConsoleColor.DarkGray); + IDatabase? db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Cleaned, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _cloneCmd.AddOption(_sourceOpt); + _cloneCmd.AddOption(_vaultOpt); + _cloneCmd.AddOption(_limitOpt); + _cloneCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving clone information...", ConsoleColor.DarkGray); + IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Cloned, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _pruneCmd.AddOption(_sourceOpt); + _pruneCmd.AddOption(_vaultOpt); + _pruneCmd.AddOption(_limitOpt); + _pruneCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving prune information...", ConsoleColor.DarkGray); + IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Pruned, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + + _pullCmd.AddOption(_sourceOpt); + _pullCmd.AddOption(_vaultOpt); + _pullCmd.AddOption(_limitOpt); + _pullCmd.SetHandler((path, config, limit) => + { + CommandLine.WriteLine($"Retrieving restore information...", ConsoleColor.DarkGray); + IDatabase db = DatabaseConnection.CreateNew(VaultConfig.Load(Program.Settings, config)); + + if (limit == 0) limit = Limit; + DisplayHistories(db?.GetHistory(path, HistoryType.Pulled, limit).ToArray()); + }, _sourceOpt, _vaultOpt, _limitOpt); + } + + private void DisplayHistories(HistoryEvent[]? histories) + { + if (histories?.Length == 0) + { + CommandLine.WriteWarning("No backup history found!"); + return; + } + + foreach (HistoryEvent history in histories.ToArray()) + { + string typeStr = (history.Type + ":").PadRight(9); + CommandLine.WriteLine($"[{Formatter.FromDateTime(history.CreatedAt.ToLocalTime())}] <{history.Vault}> {typeStr} {history.Fullname}", ConsoleColor.White); + } + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/PullCommand.cs b/Parallel.Cli/Commands/PullCommand.cs new file mode 100644 index 0000000..23d40ba --- /dev/null +++ b/Parallel.Cli/Commands/PullCommand.cs @@ -0,0 +1,9 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Cli.Commands +{ + public class PullCommand + { + + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index bfec12c..8c97bfb 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -88,13 +88,13 @@ await Program.Settings.ForEachVaultAsync(async (vault) => CommandLine.WriteLine($"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); await sync.PushFilesAsync(files, new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files.", ConsoleColor.Green); + CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); }); } private async Task SyncFileAsync(string path) { - await Program.Settings.ForEachVaultAsync(async (vault) => + await Program.Settings.ForEachVaultAsync(async vault => { ISyncManager sync = SyncManager.CreateNew(vault); if (!sync.Initialize()) @@ -104,15 +104,13 @@ await Program.Settings.ForEachVaultAsync(async (vault) => } string[] backupFolders = vault.BackupDirectories.ToArray(); + string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); if (!backupFolders.Any(path.StartsWith)) { CommandLine.WriteWarning($"The provided file is not set to be backed up!"); return; } - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); - FileScanner scanner = new FileScanner(sync); - // Checks if the file can be backed up. if (FileScanner.IsIgnored(path, ignoredFolders)) { @@ -122,7 +120,6 @@ await Program.Settings.ForEachVaultAsync(async (vault) => SystemFile localFile = new SystemFile(path); SystemFile? remoteFile = await sync.Database.GetFileAsync(localFile.LocalPath); - if (!FileScanner.HasChanged(localFile, remoteFile)) { CommandLine.WriteLine($"The provided file is already up to date.", ConsoleColor.Green); @@ -131,7 +128,7 @@ await Program.Settings.ForEachVaultAsync(async (vault) => CommandLine.WriteLine($"Pushing: {localFile.LocalPath}", ConsoleColor.DarkGray); await sync.PushFilesAsync([localFile], new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed: {localFile.LocalPath}", ConsoleColor.Green); + CommandLine.WriteLine($"Successfully pushed '{localFile.LocalPath}' to '{vault.FileSystem.Address}'.", ConsoleColor.Green); }); } } diff --git a/Parallel.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index a4c2eb4..d0b4c2c 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -87,6 +87,16 @@ public async Task AddHistoryAsync(string path, HistoryType type) return await connection.ExecuteAsync(sql, new { ProfileId, Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; } + public IEnumerable? GetHistory(string path, int limit) + { + throw new NotImplementedException(); + } + + public IEnumerable? GetHistory(string path, HistoryType type, int limit) + { + throw new NotImplementedException(); + } + #endregion } } \ No newline at end of file diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs index c65f6cd..3fd19f0 100644 --- a/Parallel.Core/Database/DatabaseConnection.cs +++ b/Parallel.Core/Database/DatabaseConnection.cs @@ -17,9 +17,9 @@ public enum DatabaseProvider /// public class DatabaseConnection { - public static IDatabase CreateNew(VaultConfig vault) + public static IDatabase? CreateNew(VaultConfig? vault) { - switch(vault.Database.Provider) + switch(vault?.Database.Provider) { default: return null; diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index c39e5f8..65fea25 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -91,6 +91,10 @@ public interface IDatabase /// True if successful, false otherwise Task AddHistoryAsync(string path, HistoryType type); + IEnumerable? GetHistory(string path, int limit); + + IEnumerable? GetHistory(string path, HistoryType type, int limit); + #endregion Task> GetFilesAsync(string path, bool deleted); diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index 3a6e389..941d721 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -140,13 +140,11 @@ public Task PingAsync() public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { if (!files.Any()) return; - for (int i = 0; i < files.Length; i++) + await Task.WhenAll(files.Select(file => Task.Run(async () => { Stopwatch sw = new Stopwatch(); - SystemFile file = files[i]; file.RemotePath = PathBuilder.Remote(file.LocalPath, _vault); - progress.Report(ProgressOperation.Uploading, file, i, files.Length); if (File.Exists(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); string parent = Path.GetDirectoryName(file.RemotePath); if (!Directory.Exists(parent)) Directory.CreateDirectory(parent); @@ -158,7 +156,7 @@ public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progres Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); - } + }))); } } } \ No newline at end of file diff --git a/Parallel.Core/Models/HistoryEvent.cs b/Parallel.Core/Models/HistoryEvent.cs index 320c483..e8a4d49 100644 --- a/Parallel.Core/Models/HistoryEvent.cs +++ b/Parallel.Core/Models/HistoryEvent.cs @@ -1,9 +1,15 @@ // Copyright 2025 Kyle Ebbinga +using Parallel.Core.Database; +using Parallel.Core.Utils; + namespace Parallel.Core.Models { public class HistoryEvent { - + public HistoryType Type { get; set; } + public UnixTime CreatedAt { get; set; } + public string Vault { get; set; } + public string Fullname { get; set; } } } \ No newline at end of file diff --git a/Parallel.Core/Settings/DatabaseCredentials.cs b/Parallel.Core/Settings/DatabaseCredentials.cs index 306c45a..761427b 100644 --- a/Parallel.Core/Settings/DatabaseCredentials.cs +++ b/Parallel.Core/Settings/DatabaseCredentials.cs @@ -24,12 +24,12 @@ public class DatabaseCredentials /// /// The username of the database. /// - public string Username { get; set; } = string.Empty; + public string? Username { get; set; } /// /// The password of the database. /// - public string Password { get; set; } = string.Empty; + public string? Password { get; set; } /// /// The database name. diff --git a/Parallel.Core/Settings/FileSystemCredentials.cs b/Parallel.Core/Settings/FileSystemCredentials.cs index db02bf1..8000bf9 100644 --- a/Parallel.Core/Settings/FileSystemCredentials.cs +++ b/Parallel.Core/Settings/FileSystemCredentials.cs @@ -13,9 +13,9 @@ public class FileSystemCredentials { public FileService Service { get; set; } = FileService.Local; public string RootDirectory { get; set; } = string.Empty; - public string Address { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; + public string? Address { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } /// /// If the file system is encrypting files. From 4cd81e79a34b7db6b64a2f9be31f100b63530bd8 Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:43:40 -0500 Subject: [PATCH 12/14] Optimized for pushing to multiple vaults --- Parallel.Cli/Commands/HistoryCommand.cs | 2 +- Parallel.Cli/Commands/PushCommand.cs | 89 +++++-------------- Parallel.Cli/Utils/CommandLine.cs | 65 ++++++++++---- Parallel.Cli/Utils/ProgressReport.cs | 6 +- .../{ProgressDebug.cs => ProgressLogger.cs} | 2 +- Parallel.Core/IO/Scanning/FileScanner.cs | 11 ++- Parallel.Core/IO/Syncing/ISyncManager.cs | 3 +- Parallel.Core/IO/Syncing/SyncManager.cs | 1 + Parallel.Core/Settings/ParallelSettings.cs | 28 ++++-- Parallel.Core/Settings/VaultConfig.cs | 1 + 10 files changed, 110 insertions(+), 98 deletions(-) rename Parallel.Core/Diagnostics/{ProgressDebug.cs => ProgressLogger.cs} (95%) diff --git a/Parallel.Cli/Commands/HistoryCommand.cs b/Parallel.Cli/Commands/HistoryCommand.cs index eef71c2..eabd7cb 100644 --- a/Parallel.Cli/Commands/HistoryCommand.cs +++ b/Parallel.Cli/Commands/HistoryCommand.cs @@ -125,7 +125,7 @@ private void DisplayHistories(HistoryEvent[]? histories) { if (histories?.Length == 0) { - CommandLine.WriteWarning("No backup history found!"); + CommandLine.WriteLine("No backup history found!", ConsoleColor.Yellow); return; } diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs index 8c97bfb..7d7961c 100644 --- a/Parallel.Cli/Commands/PushCommand.cs +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -5,7 +5,9 @@ using Parallel.Core.IO; using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; using Parallel.Core.Models; +using Parallel.Core.Settings; namespace Parallel.Cli.Commands { @@ -19,7 +21,7 @@ public class PushCommand : Command private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); - public PushCommand() : base("push", "Pushes files to ") + public PushCommand() : base("push", "Pushes changed files to vaults.") { this.AddOption(_sourceArg); this.AddOption(_configOpt); @@ -32,15 +34,9 @@ public PushCommand() : base("push", "Pushes files to ") } else { - if (PathBuilder.IsDirectory(path)) - { - await SyncDirectoryAsync(path); - } - else if (PathBuilder.IsFile(path)) - { - await SyncFileAsync(path); - } + await SyncPathAsync(path); } + }, _sourceArg, _configOpt, _verboseOpt); } @@ -49,86 +45,49 @@ private async Task SyncSystemAsync() throw new NotImplementedException(); } - private async Task SyncDirectoryAsync(string path) + private async Task SyncPathAsync(string path) { - await Program.Settings.ForEachVaultAsync(async (vault) => + await ParallelSettings.ForEachVaultAsync(async vault => { ISyncManager sync = SyncManager.CreateNew(vault); if (!sync.Initialize()) { - CommandLine.WriteError($"Failed to connect to vault '{vault.Name}'!"); + CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); return; } + // Normalize paths for safe comparison + string fullPath = Path.GetFullPath(path); string[] backupFolders = vault.BackupDirectories.ToArray(); - if (!backupFolders.Any(path.StartsWith)) - { - CommandLine.WriteWarning($"The provided folder is not set to be backed up!"); - return; - } - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); - FileScanner scanner = new FileScanner(sync); - // Checks if the file can be backed up. - if (FileScanner.IsIgnored(path, ignoredFolders)) + bool isFile = PathBuilder.IsFile(fullPath); + if (!backupFolders.Any(dir => fullPath.StartsWith(dir, StringComparison.OrdinalIgnoreCase))) { - CommandLine.WriteWarning($"The provided folder is set to be ignored!"); + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is not set to be backed up!", ConsoleColor.Yellow); return; } - CommandLine.WriteLine($"Scanning for file changes in {path}...", ConsoleColor.DarkGray); - SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); - int successFiles = files.Length; - if (successFiles == 0) + if (FileScanner.IsIgnored(fullPath, ignoredFolders)) { - CommandLine.WriteLine($"The provided directory is already up to date.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is set to be ignored!", ConsoleColor.Yellow); return; } - CommandLine.WriteLine($"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - await sync.PushFilesAsync(files, new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); - }); - } + CommandLine.WriteLine(vault, $"Scanning for file changes in {path}...", ConsoleColor.DarkGray); - private async Task SyncFileAsync(string path) - { - await Program.Settings.ForEachVaultAsync(async vault => - { - ISyncManager sync = SyncManager.CreateNew(vault); - if (!sync.Initialize()) - { - CommandLine.WriteError("Failed to connect to backup file system!"); - return; - } - - string[] backupFolders = vault.BackupDirectories.ToArray(); - string[] ignoredFolders = vault.IgnoreDirectories.ToArray(); - if (!backupFolders.Any(path.StartsWith)) - { - CommandLine.WriteWarning($"The provided file is not set to be backed up!"); - return; - } - - // Checks if the file can be backed up. - if (FileScanner.IsIgnored(path, ignoredFolders)) - { - CommandLine.WriteWarning($"The provided folder is set to be ignored!"); - return; - } - - SystemFile localFile = new SystemFile(path); - SystemFile? remoteFile = await sync.Database.GetFileAsync(localFile.LocalPath); - if (!FileScanner.HasChanged(localFile, remoteFile)) + FileScanner scanner = new FileScanner(sync); + SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); + int successFiles = files.Length; + if (successFiles == 0) { - CommandLine.WriteLine($"The provided file is already up to date.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is already up to date.", ConsoleColor.Green); return; } - CommandLine.WriteLine($"Pushing: {localFile.LocalPath}", ConsoleColor.DarkGray); - await sync.PushFilesAsync([localFile], new ProgressReport()); - CommandLine.WriteLine($"Successfully pushed '{localFile.LocalPath}' to '{vault.FileSystem.Address}'.", ConsoleColor.Green); + CommandLine.WriteLine(vault, $"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + await sync.PushFilesAsync(files, new ProgressReport(vault)); + CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.Address}'.", ConsoleColor.Green); }); } } diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs index e2687d0..049cc89 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -1,12 +1,15 @@ // Copyright 2025 Kyle Ebbinga using System.Text; +using Parallel.Core.Settings; using Parallel.Core.Utils; namespace Parallel.Cli.Utils { public class CommandLine { + private static readonly object _consoleLock = new(); + public static string ReadString(object value, ConsoleColor color = ConsoleColor.Gray) { Console.ForegroundColor = color; @@ -71,28 +74,56 @@ public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) Console.ResetColor(); } - public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) + public static void WriteLine(VaultConfig vault, object value, ConsoleColor color = ConsoleColor.Gray) { - Log.Information(value.ToString()!); - Console.ForegroundColor = color; - Console.WriteLine($"> {value}"); - Console.ResetColor(); - } + string baseLog = $"[{vault.Id}] {value}"; + switch(color) + { + default: + Log.Information(baseLog); + break; - public static void WriteWarning(object value) - { - Log.Warning(value.ToString()!); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"> {value}"); - Console.ResetColor(); + case ConsoleColor.Yellow: + Log.Warning(baseLog); + break; + + case ConsoleColor.Red: + Log.Error(baseLog); + break; + } + + lock (_consoleLock) + { + Console.ForegroundColor = color; + Console.WriteLine($"> {baseLog}"); + Console.ResetColor(); + } } - public static void WriteError(object value) + public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) { - Log.Error(value.ToString()!); - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"> {value}"); - Console.ResetColor(); + string baseLog = $"{value}"; + switch(color) + { + default: + Log.Information(baseLog); + break; + + case ConsoleColor.Yellow: + Log.Warning(baseLog); + break; + + case ConsoleColor.Red: + Log.Error(baseLog); + break; + } + + lock (_consoleLock) + { + Console.ForegroundColor = color; + Console.WriteLine($"> {baseLog}"); + Console.ResetColor(); + } } public static void ProgressBar(double part, double total, TimeSpan elapsed, ConsoleColor color = ConsoleColor.Gray) diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index eacc676..9710575 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -6,17 +6,17 @@ namespace Parallel.Cli.Utils { - public class ProgressReport : IProgressReporter + public class ProgressReport(VaultConfig vault) : IProgressReporter { public void Report(ProgressOperation operation, SystemFile file, int current, int total) { int percent = current * 100 / total; - CommandLine.WriteLine($"[{percent}%] {operation}: {file.LocalPath}"); + CommandLine.WriteLine($"[{percent}%] <{vault.Id}> {operation}: {file.LocalPath}"); } public void Failed(Exception exception, SystemFile file) { - CommandLine.WriteLine($"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); + CommandLine.WriteLine(vault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); } } } \ No newline at end of file diff --git a/Parallel.Core/Diagnostics/ProgressDebug.cs b/Parallel.Core/Diagnostics/ProgressLogger.cs similarity index 95% rename from Parallel.Core/Diagnostics/ProgressDebug.cs rename to Parallel.Core/Diagnostics/ProgressLogger.cs index 9ca0b6e..c624e39 100644 --- a/Parallel.Core/Diagnostics/ProgressDebug.cs +++ b/Parallel.Core/Diagnostics/ProgressLogger.cs @@ -7,7 +7,7 @@ namespace Parallel.Core.Diagnostics /// /// Represents a basic progress report debugger. /// - public class ProgressDebug : IProgressReporter + public class ProgressLogger : IProgressReporter { private ProgressOperation currentOperation; private int progressPercentage; diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 013ae05..699af81 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.Backup; +using Parallel.Core.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -105,6 +106,12 @@ public async Task GetFileChangesAsync(string path, string[] ignore return scannedFiles.ToArray(); } + /// + /// Gets if a file has changed. + /// + /// The base file to compare. + /// The remote file to compare to. + /// True is success, otherwise false. public static bool HasChanged(SystemFile localFile, SystemFile? remoteFile) { return remoteFile == null || (localFile.LastWrite.TotalMilliseconds > remoteFile.LastWrite.TotalMilliseconds && !localFile.CheckSum.SequenceEqual(remoteFile.CheckSum)); @@ -207,7 +214,7 @@ public static FileInfo[] GetCleanableFiles(string path, UnixTime start, bool rec public static IEnumerable GetFiles(string root, string searchPattern) { - return GetFiles(root, searchPattern, Array.Empty()); + return GetFiles(root, searchPattern, []); } public static IEnumerable GetFiles(string root, string searchPattern, string[] exempt) @@ -217,7 +224,7 @@ public static IEnumerable GetFiles(string root, string searchPattern, st while (pending.Count != 0) { string path = pending.Pop(); - IEnumerable next = null; + IEnumerable? next = null; try { if (!IsIgnored(path, exempt)) diff --git a/Parallel.Core/IO/Syncing/ISyncManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs index d634a85..21a5641 100644 --- a/Parallel.Core/IO/Syncing/ISyncManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -2,12 +2,11 @@ using Parallel.Core.Database; using Parallel.Core.Diagnostics; -using Parallel.Core.Events; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; -namespace Parallel.Core.IO.Backup +namespace Parallel.Core.IO.Syncing { /// /// Defines the methods needed for backing up a file system. diff --git a/Parallel.Core/IO/Syncing/SyncManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs index f5b4655..4b5124f 100644 --- a/Parallel.Core/IO/Syncing/SyncManager.cs +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Parallel.Core.IO.Syncing; namespace Parallel.Core.IO.Backup { diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs index bb9c834..d323473 100644 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -80,17 +80,31 @@ public void ForEachVault(Action action) } } - public async Task ForEachVaultAsync(Func actionAsync) + /// + /// Asynchronously runs an for each with a default of 3 at a time. + /// + /// + /// + public static async Task ForEachVaultAsync(Func actionAsync, int maxDegreeOfParallelism = 3) { - List tasks = new(); - foreach (string path in Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly)) + string[] vaultPaths = Directory.GetFiles(VaultsDir, "*.json", SearchOption.TopDirectoryOnly); + SemaphoreSlim semaphore = new SemaphoreSlim(maxDegreeOfParallelism); + IEnumerable tasks = vaultPaths.Select(path => Task.Run(async () => { - VaultConfig? vault = VaultConfig.Load(path); - if (vault != null) + await semaphore.WaitAsync(); + try { - tasks.Add(actionAsync(vault)); + VaultConfig? vault = VaultConfig.Load(path); + if (vault != null) + { + await actionAsync(vault); + } } - } + finally + { + semaphore.Release(); + } + })); await Task.WhenAll(tasks); } diff --git a/Parallel.Core/Settings/VaultConfig.cs b/Parallel.Core/Settings/VaultConfig.cs index 60f2b84..c1a480d 100644 --- a/Parallel.Core/Settings/VaultConfig.cs +++ b/Parallel.Core/Settings/VaultConfig.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Database; using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; using Parallel.Core.Security; using Parallel.Core.Utils; From 8b5d7b3f2d4c1551b41a308a68474307786cb0ed Mon Sep 17 00:00:00 2001 From: Kyle | Guitar <26614720+TheGuitarleader@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:46:33 -0500 Subject: [PATCH 13/14] Moved service to separate branch --- Parallel.Core.Net/Communication.cs | 70 ---------- Parallel.Core.Net/Connections/IConnection.cs | 10 -- .../Connections/TcpConnection.cs | 88 ------------- Parallel.Core.Net/MessageResult.cs | 8 -- Parallel.Core.Net/Parallel.Core.Net.csproj | 18 --- Parallel.Core.Net/ServerRequest.cs | 31 ----- Parallel.Core.Net/ServerResponse.cs | 41 ------ Parallel.Core.Net/Sockets/ISocketHandler.cs | 42 ------ Parallel.Core.Net/Sockets/TcpSocketHandler.cs | 82 ------------ Parallel.Service/Parallel.Service.csproj | 40 ------ Parallel.Service/Program.cs | 66 ---------- Parallel.Service/RequestHandler.cs | 76 ----------- Parallel.Service/Requests/BaseRequest.cs | 63 --------- Parallel.Service/Requests/HelpRequest.cs | 50 ------- Parallel.Service/Requests/IRequest.cs | 19 --- Parallel.Service/Requests/PingRequest.cs | 14 -- Parallel.Service/Responses/ErrorResponse.cs | 16 --- Parallel.Service/Responses/IResponse.cs | 9 -- Parallel.Service/Responses/MessageResponse.cs | 16 --- Parallel.Service/Responses/ObjectResponse.cs | 16 --- .../Services/FileBackupService.cs | 14 -- .../Services/FileCleanupService.cs | 14 -- Parallel.Service/Services/LoggingService.cs | 28 ---- .../Services/TcpRequestService.cs | 122 ------------------ Parallel.Service/Utils/UdpReporting.cs | 24 ---- Parallel.Service/parallel-red.ico | Bin 180837 -> 0 bytes 26 files changed, 977 deletions(-) delete mode 100644 Parallel.Core.Net/Communication.cs delete mode 100644 Parallel.Core.Net/Connections/IConnection.cs delete mode 100644 Parallel.Core.Net/Connections/TcpConnection.cs delete mode 100644 Parallel.Core.Net/MessageResult.cs delete mode 100644 Parallel.Core.Net/Parallel.Core.Net.csproj delete mode 100644 Parallel.Core.Net/ServerRequest.cs delete mode 100644 Parallel.Core.Net/ServerResponse.cs delete mode 100644 Parallel.Core.Net/Sockets/ISocketHandler.cs delete mode 100644 Parallel.Core.Net/Sockets/TcpSocketHandler.cs delete mode 100644 Parallel.Service/Parallel.Service.csproj delete mode 100644 Parallel.Service/Program.cs delete mode 100644 Parallel.Service/RequestHandler.cs delete mode 100644 Parallel.Service/Requests/BaseRequest.cs delete mode 100644 Parallel.Service/Requests/HelpRequest.cs delete mode 100644 Parallel.Service/Requests/IRequest.cs delete mode 100644 Parallel.Service/Requests/PingRequest.cs delete mode 100644 Parallel.Service/Responses/ErrorResponse.cs delete mode 100644 Parallel.Service/Responses/IResponse.cs delete mode 100644 Parallel.Service/Responses/MessageResponse.cs delete mode 100644 Parallel.Service/Responses/ObjectResponse.cs delete mode 100644 Parallel.Service/Services/FileBackupService.cs delete mode 100644 Parallel.Service/Services/FileCleanupService.cs delete mode 100644 Parallel.Service/Services/LoggingService.cs delete mode 100644 Parallel.Service/Services/TcpRequestService.cs delete mode 100644 Parallel.Service/Utils/UdpReporting.cs delete mode 100644 Parallel.Service/parallel-red.ico diff --git a/Parallel.Core.Net/Communication.cs b/Parallel.Core.Net/Communication.cs deleted file mode 100644 index eb9f480..0000000 --- a/Parallel.Core.Net/Communication.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using System.Net.Sockets; -using System.Text; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Parallel.Core.Events; -using Parallel.Core.Utils; - -namespace Parallel.Core.Net -{ - /// - /// Represents UDP communication between services. - /// - public class Communication - { - private readonly CancellationTokenSource _exit = new(); - private bool _active; - - /// - /// The primary client for network communication. - /// - public UdpClient Client { get; } = new UdpClient(); - - public event EventHandler RecievedMessage; - - public Communication() - { - Client = new UdpClient(); - } - - public Communication(int port) - { - Client = new UdpClient(new IPEndPoint(IPAddress.Any, port)); - } - - /// - /// Starts listening for messages. - /// - public async Task Start() - { - _active = true; - while (_active && !_exit.IsCancellationRequested) - { - UdpReceiveResult result = await Client.ReceiveAsync(_exit.Token); - RecievedMessage?.Invoke(this, new MessageRecievedEventArgs(result)); - } - } - - /// - /// Stops listening for messages. - /// - public void Stop() - { - _active = false; - _exit.Cancel(); - } - - /// - /// Sends a message to a specified port on a specified remote host. - /// - /// - /// - public void Send(string message, IPEndPoint endPoint) - { - Client.Send(Encoding.UTF8.GetBytes(Encryption.Encode(message)), endPoint); - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Connections/IConnection.cs b/Parallel.Core.Net/Connections/IConnection.cs deleted file mode 100644 index b87b4e4..0000000 --- a/Parallel.Core.Net/Connections/IConnection.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Core.Net.Connections -{ - public interface IConnection - { - ServerResponse SendRequest(ServerRequest request); - //Task SendRequestAsync(ServerRequest request); - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Connections/TcpConnection.cs b/Parallel.Core.Net/Connections/TcpConnection.cs deleted file mode 100644 index 7e75d9c..0000000 --- a/Parallel.Core.Net/Connections/TcpConnection.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net.Sockets; -using System.Text; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Parallel.Core.Utils; -using Serilog; -using Serilog.Core; - -namespace Parallel.Core.Net.Connections -{ - public class TcpConnection : IConnection - { - private readonly string _address; - private readonly int _port; - - /// - /// Initializes a new instance of the class with the saved settings. - /// - public TcpConnection() - { - _address = "127.0.0.1"; - _port = 8192; - } - - /// - /// Initializes a new instance of the class with a address and port. - /// - public TcpConnection(string address, int port) - { - _address = address; - _port = port; - } - - public ServerResponse SendRequest(ServerRequest request) - { - Socket socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.DontLinger, true); - ServerResponse response = new(request); - - try - { - socket.Connect(_address, _port); - if (socket.Connected) - { - // Sends an encrypted json request to the server. - string rawJson = JsonConvert.SerializeObject(request) + ";"; - Log.Debug($"Sending request: '{rawJson}'"); - socket.Send(Encoding.UTF8.GetBytes(rawJson)); - - // The encrypted returned json - string returnedData = string.Empty; - using (NetworkStream ns = new(socket)) - { - while (!returnedData.EndsWith(';')) - { - Console.WriteLine("Waiting for response..."); - byte[] buffer = new byte[socket.ReceiveBufferSize]; - int bytesRead = ns.Read(buffer, 0, socket.ReceiveBufferSize); - returnedData += Encoding.UTF8.GetString(buffer, 0, bytesRead); - } - } - - Log.Debug($"Response: {returnedData}"); - JToken? json = JToken.Parse(returnedData.TrimEnd(';')); - response = ServerResponse.Parse(request, json); - - // Closes the socket. - socket.Shutdown(SocketShutdown.Both); - socket.Close(); - return response; - } - else - { - Log.Warning($"Failed to connect to server '{_address}:{_port}'"); - return response; - } - } - catch (Exception ex) - { - Log.Warning(ex.Message); - return response; - } - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/MessageResult.cs b/Parallel.Core.Net/MessageResult.cs deleted file mode 100644 index 0bd428a..0000000 --- a/Parallel.Core.Net/MessageResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Core.Net -{ - public struct MessageResult - { - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Parallel.Core.Net.csproj b/Parallel.Core.Net/Parallel.Core.Net.csproj deleted file mode 100644 index be6daf4..0000000 --- a/Parallel.Core.Net/Parallel.Core.Net.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - diff --git a/Parallel.Core.Net/ServerRequest.cs b/Parallel.Core.Net/ServerRequest.cs deleted file mode 100644 index 932865c..0000000 --- a/Parallel.Core.Net/ServerRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json; - -namespace Parallel.Core.Net -{ - public class ServerRequest - { - /// - /// The request name. - /// - public string Name { get; } - - /// - /// The request parameters. - /// - public Dictionary Parameters { get; } - - /// - /// Initializes new instance of the class with a request name and a of parameters. - /// - /// The request name. - /// A collection of parameter keys and values. - [JsonConstructor] - public ServerRequest(string name, Dictionary parameters) - { - Name = name.ToLower(); - Parameters = parameters; - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/ServerResponse.cs b/Parallel.Core.Net/ServerResponse.cs deleted file mode 100644 index 103438c..0000000 --- a/Parallel.Core.Net/ServerResponse.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json.Linq; - -namespace Parallel.Core.Net.Connections -{ - public class ServerResponse - { - public ServerRequest Request { get; } - public int StatusCode { get; } - public bool Success { get; } = false; - public JToken? Data { get; } - public string? Message { get; } - public string? Error { get; } - - public ServerResponse(ServerRequest request) - { - Request = request; - } - - private ServerResponse(ServerRequest request, JToken? data, string? message, string? error, int statusCode) - { - Request = request; - StatusCode = statusCode; - Success = statusCode == 200; - Data = data; - Message = message; - Error = error; - } - - public static ServerResponse Parse(ServerRequest request, JToken? json) - { - int statusCode = json?["status"]?.Value() ?? 408; - JToken? data = json?["data"]; - string? message = json?.Value("message"); - string? error = json?.Value("error"); - - return new ServerResponse(request, data, message, error, statusCode); - } - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Sockets/ISocketHandler.cs b/Parallel.Core.Net/Sockets/ISocketHandler.cs deleted file mode 100644 index 08398c3..0000000 --- a/Parallel.Core.Net/Sockets/ISocketHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using Parallel.Core.Utils; - -namespace Parallel.Core.Net.Sockets -{ - public interface ISocketHandler - { - /// - /// The time the socket was received. - /// - UnixTime ReceivedAt { get; } - - /// - /// The raw string of incoming data decrypted. - /// - string RawData { get; set; } - - /// - /// The remote client that sent the request. - /// - IPEndPoint RemoteEndPoint { get; } - - /// - /// Shuts down the , closes the connection, and releases all resources. - /// - void Close(); - - /// - /// Reads the incoming encrypted data as formatted JSON string. - /// - /// - ServerRequest? Parse(); - - /// - /// Responds to the current request. - /// - /// - Task RespondAsync(object? data); - } -} \ No newline at end of file diff --git a/Parallel.Core.Net/Sockets/TcpSocketHandler.cs b/Parallel.Core.Net/Sockets/TcpSocketHandler.cs deleted file mode 100644 index de68a70..0000000 --- a/Parallel.Core.Net/Sockets/TcpSocketHandler.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using System.Net.Sockets; -using System.Text; -using Newtonsoft.Json; -using Parallel.Core.Utils; -using Serilog; -using Serilog.Core; - -namespace Parallel.Core.Net.Sockets -{ - public class TcpSocketHandler : ISocketHandler - { - /// - /// - /// - public Socket Socket { get; } - - /// - public UnixTime ReceivedAt { get; } - - /// - public string RawData { get; set; } = string.Empty; - - /// - public IPEndPoint RemoteEndPoint { get; } - - /// - /// Initializes a new instance of the class for the specified socket. - /// - /// The socket to handle. - public TcpSocketHandler(Socket socket) - { - ReceivedAt = UnixTime.Now; - Socket = socket; - RemoteEndPoint = (IPEndPoint)socket.RemoteEndPoint; - } - - public void Close() - { - Socket.Shutdown(SocketShutdown.Both); - Socket.Close(); - } - - public ServerRequest? Parse() - { - using (NetworkStream ns = new(Socket)) - { - while (!RawData.EndsWith(';')) - { - byte[] buffer = new byte[Socket.ReceiveBufferSize]; - int bytesRead = ns.Read(buffer, 0, Socket.ReceiveBufferSize); - RawData += Encoding.UTF8.GetString(buffer, 0, bytesRead); - Log.Debug(RawData); - } - } - - return JsonConvert.DeserializeObject(RawData.TrimEnd(';')); - } - - public Task RespondAsync(object? data) - { - try - { - string json = JsonConvert.SerializeObject(data, Formatting.Indented); - Socket?.Send(Encoding.UTF8.GetBytes(json + ";")); - Close(); - return Task.CompletedTask; - } - catch (ObjectDisposedException) - { - throw; - } - catch (Exception ex) - { - Log.Error(ex.Message); - return Task.CompletedTask; - } - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Parallel.Service.csproj b/Parallel.Service/Parallel.Service.csproj deleted file mode 100644 index bfde18a..0000000 --- a/Parallel.Service/Parallel.Service.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - Exe - net9.0 - enable - parallel-red.ico - enable - ParallelService - 1.0.0 - Kyle Ebbinga - Copyright $(Company). All Rights Reserved. - $(AssemblyVersion) - $(VersionPrefix)$(AssemblyVersion) - $(Company) - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Parallel.Service/Program.cs b/Parallel.Service/Program.cs deleted file mode 100644 index 8950aa8..0000000 --- a/Parallel.Service/Program.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Reflection; -using System.Runtime.InteropServices; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Parallel.Core.IO; -using Parallel.Core.Settings; -using Parallel.Core.Utils; -using Parallel.Service.Requests; -using Parallel.Service.Services; - -namespace Parallel.Service -{ - internal class Program - { - internal static readonly string LogFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); - - static async Task Main(string[] args) - { - HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); - - // Logging - Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().WriteTo.File(LogFile).CreateLogger(); - builder.Logging.ClearProviders(); - builder.Logging.AddSerilog(); - - AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); - Log.Information($"{assembly.Name} v{assembly.Version}"); - - // Add Windows services - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - builder.Services.AddWindowsService(); - } - - // Add Linux systemd - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - builder.Services.AddSystemd(); - } - - // Background services - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - - // Other services - builder.Services.AddSingleton(ParallelSettings.Load()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - IHost host = builder.Build(); - IHostApplicationLifetime lifetime = host.Services.GetRequiredService(); - ParallelSettings settings = host.Services.GetRequiredService(); - lifetime.ApplicationStopped.Register(() => - { - settings.Save(); - }); - - // Starts the application - await host.RunAsync(); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/RequestHandler.cs b/Parallel.Service/RequestHandler.cs deleted file mode 100644 index a121c6e..0000000 --- a/Parallel.Service/RequestHandler.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using Parallel.Core.Net; -using Parallel.Service.Requests; - -namespace Parallel.Service -{ - public class RequestHandler - { - public Dictionary Requests { get; } - - public RequestHandler() - { - Type[] types = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).Where(t => typeof(BaseRequest).IsAssignableFrom(t) && !t.IsAbstract).ToArray(); - Requests = types.ToDictionary(t => t.Name.Replace("Request", ""), t => t, StringComparer.OrdinalIgnoreCase); - - // Logs if any requests failed - if (Requests.Count != types.Length) - { - int remaining = types.Length - Requests.Count; - Log.Warning($"Failed to register {remaining} requests"); - } - } - - /// - /// Creates an to be handled. - /// - /// The name of the request. - /// The corresponding . If none was found a help request will be returned. - public IRequest? CreateNew(ServerRequest request) - { - Dictionary headers = new Dictionary(request.Parameters, StringComparer.OrdinalIgnoreCase); - if (!Requests.TryGetValue(request.Name, out Type? requestType)) - { - Log.Warning($"Unknown command: {request.Name}"); - return null; - } - - // Instantiate the request object - object? instance = Activator.CreateInstance(requestType); - if (instance is not IRequest requestInstance) return null; - - // Map parameters to object properties - foreach (PropertyInfo prop in requestType.GetProperties()) - { - if (headers.TryGetValue(prop.Name, out string? value)) - { - try - { - object? converted = Convert.ChangeType(value, prop.PropertyType); - prop.SetValue(instance, converted); - } - catch (Exception ex) - { - Log.Warning($"Failed to convert '{value}' to {prop.PropertyType.Name} for property '{prop.Name}': {ex.Message}"); - } - } - } - - - // Validate required properties - List? validationResults = new List(); - ValidationContext? context = new ValidationContext(instance, serviceProvider: null, items: null); - if (!Validator.TryValidateObject(instance, context, validationResults, validateAllProperties: true)) - { - string? errors = string.Join("; ", validationResults.Select(r => r.ErrorMessage)); - Log.Warning($"Validation failed for '{request.Name}': {errors}"); - return null; - } - - return requestInstance; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/BaseRequest.cs b/Parallel.Service/Requests/BaseRequest.cs deleted file mode 100644 index f515432..0000000 --- a/Parallel.Service/Requests/BaseRequest.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Net.Sockets; -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - /// - /// The base implementation for an - /// - public abstract class BaseRequest : IRequest - { - protected ISocketHandler Handler { get; } - - public abstract Task ExecuteAsync(); - - public virtual void Dispose() - { - Handler.Close(); - GC.SuppressFinalize(this); - } - - public static MessageResponse Ok() - { - return new MessageResponse("Success", 200); - } - - public static MessageResponse Ok(string message) - { - return new MessageResponse(message, 200); - } - - public static ObjectResponse Json(object data) - { - return new ObjectResponse(data, 200); - } - - public static MessageResponse BadRequest(string message) - { - return new MessageResponse(message, 401); - } - - public static MessageResponse Unauthorized() - { - return new MessageResponse("Unauthorized", 401); - } - - public static MessageResponse Forbidden() - { - return new MessageResponse("Forbidden", 403); - } - - public static ErrorResponse InternalServerError(Exception exception) - { - return new ErrorResponse(exception, 500); - } - - public static MessageResponse NotImplemented() - { - return new MessageResponse("Function not implemented", 501); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/HelpRequest.cs b/Parallel.Service/Requests/HelpRequest.cs deleted file mode 100644 index 7d4ee43..0000000 --- a/Parallel.Service/Requests/HelpRequest.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using Newtonsoft.Json.Linq; -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - [Description("Lists all avalible requests to the server.")] - public class HelpRequest : BaseRequest - { - public override Task ExecuteAsync() - { - RequestHandler handler = new RequestHandler(); - - JArray jsonArray = new JArray(); - foreach (KeyValuePair request in handler.Requests.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) - { - Type type = request.Value; - DescriptionAttribute? descAttr = type.GetCustomAttribute(); - string description = descAttr?.Description ?? "No description provided."; - - JArray parameters = new JArray(); - foreach (PropertyInfo prop in type.GetProperties()) - { - parameters.Add(new JObject - { - ["name"] = prop.Name, - ["type"] = prop.PropertyType.Name, - ["required"] = prop.GetCustomAttribute() != null - }); - } - - // Build JObject for this request - JObject summary = new JObject - { - ["name"] = request.Key, - ["description"] = description, - ["parameters"] = parameters - }; - - jsonArray.Add(summary); - } - - return Task.FromResult(Json(jsonArray)); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/IRequest.cs b/Parallel.Service/Requests/IRequest.cs deleted file mode 100644 index 88e6286..0000000 --- a/Parallel.Service/Requests/IRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Net; -using Parallel.Core.Net.Sockets; -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - /// - /// Defines the request class. - /// - public interface IRequest : IDisposable - { - /// - /// Executes a request and responds with an . - /// - Task ExecuteAsync(); - } -} \ No newline at end of file diff --git a/Parallel.Service/Requests/PingRequest.cs b/Parallel.Service/Requests/PingRequest.cs deleted file mode 100644 index ea918b6..0000000 --- a/Parallel.Service/Requests/PingRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Service.Responses; - -namespace Parallel.Service.Requests -{ - public class PingRequest : BaseRequest - { - public override Task ExecuteAsync() - { - return Task.FromResult(Ok()); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/ErrorResponse.cs b/Parallel.Service/Responses/ErrorResponse.cs deleted file mode 100644 index c726119..0000000 --- a/Parallel.Service/Responses/ErrorResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public class ErrorResponse : IResponse - { - public int Status { get; } - public string Error { get; } - - public ErrorResponse(Exception exception, int status) - { - Status = status; - Error = $"{exception.GetType().FullName}: {exception.Message}"; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/IResponse.cs b/Parallel.Service/Responses/IResponse.cs deleted file mode 100644 index f48de5e..0000000 --- a/Parallel.Service/Responses/IResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public interface IResponse - { - int Status { get; } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/MessageResponse.cs b/Parallel.Service/Responses/MessageResponse.cs deleted file mode 100644 index f03a76f..0000000 --- a/Parallel.Service/Responses/MessageResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public class MessageResponse : IResponse - { - public int Status { get; } - public string Message { get; } - - public MessageResponse(string message, int status) - { - Message = message; - Status = status; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Responses/ObjectResponse.cs b/Parallel.Service/Responses/ObjectResponse.cs deleted file mode 100644 index 1e3577b..0000000 --- a/Parallel.Service/Responses/ObjectResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Service.Responses -{ - public sealed class ObjectResponse : IResponse - { - public int Status { get; } - public object? Data { get; } - - public ObjectResponse(object? data, int status) - { - Status = status; - Data = data; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/FileBackupService.cs b/Parallel.Service/Services/FileBackupService.cs deleted file mode 100644 index 2c91ca5..0000000 --- a/Parallel.Service/Services/FileBackupService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Microsoft.Extensions.Hosting; - -namespace Parallel.Service.Services -{ - public class FileBackupService : BackgroundService - { - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/FileCleanupService.cs b/Parallel.Service/Services/FileCleanupService.cs deleted file mode 100644 index e1abcc1..0000000 --- a/Parallel.Service/Services/FileCleanupService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Microsoft.Extensions.Hosting; - -namespace Parallel.Service.Services -{ - public class FileCleanupService : BackgroundService - { - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/LoggingService.cs b/Parallel.Service/Services/LoggingService.cs deleted file mode 100644 index 2e1c644..0000000 --- a/Parallel.Service/Services/LoggingService.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Microsoft.Extensions.Hosting; -using Parallel.Core.IO; - -namespace Parallel.Service.Services -{ - public class LoggingService : BackgroundService - { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - await Task.Delay(GetTimeUntilNextDay(), stoppingToken); - await Log.CloseAndFlushAsync(); - - File.Move(Program.LogFile, Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log")); - } - } - - private static TimeSpan GetTimeUntilNextDay() - { - DateTime current = DateTime.Now; - DateTime nextMidnight = current.AddDays(1); - return nextMidnight - current; - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Services/TcpRequestService.cs b/Parallel.Service/Services/TcpRequestService.cs deleted file mode 100644 index 7b45152..0000000 --- a/Parallel.Service/Services/TcpRequestService.cs +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Net; -using System.Net.Sockets; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Parallel.Core.Net; -using Parallel.Core.Net.Sockets; -using Parallel.Core.Settings; -using Parallel.Service.Requests; -using Parallel.Service.Responses; - -namespace Parallel.Service.Services -{ - public class TcpRequestService : BackgroundService - { - // Privates - private readonly CancellationTokenSource _exit = new(); - private readonly ILogger _logger; - private readonly Socket _listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - private readonly ParallelSettings _settings; - private readonly RequestHandler _requests; - private readonly List _requestPool = new List(); - - public TcpRequestService(ILogger logger, ParallelSettings settings, RequestHandler requests) - { - _logger = logger; - _settings = settings; - _requests = requests; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - // Starts listening for requests over the TCP socket. - IPAddress address = string.IsNullOrEmpty(_settings.Address) ? IPAddress.Any : IPAddress.Parse(_settings.Address); - _listener.Bind(new IPEndPoint(address, _settings.ListenerPort)); - _listener.Listen(5); - } - catch - { - _logger.LogError("Failed to start server! This usually means either the port is already in use or another instance of Parallel is currently running."); - Environment.Exit(1); - } - - // Starts listening for connections - _logger.LogInformation($"Listening for commands on: {_listener.LocalEndPoint}"); - while (!stoppingToken.IsCancellationRequested && !_exit.IsCancellationRequested) - { - _requestPool.RemoveAll(c => c.IsCompleted); - Socket requestSocket = await _listener.AcceptAsync(_exit.Token); - StartHandlingRequests(requestSocket, stoppingToken); - } - } - - private void StartHandlingRequests(Socket socket, CancellationToken token) - { - TcpSocketHandler handler = new(socket); - Task handleTask = AcceptRequestAsync(handler); - Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(30), token); - - Task wrappedTask = Task.Run(async () => - { - Task completed = await Task.WhenAny(handleTask, timeoutTask); - IResponse response; - - if (completed == handleTask) - { - try - { - response = await handleTask; - } - catch (OperationCanceledException) - { - _logger.LogInformation($"[{handler.RemoteEndPoint}]: Request cancelled."); - response = new MessageResponse("Request cancelled", 503); - } - catch (Exception ex) - { - _logger.LogError(ex, $"[{handler.RemoteEndPoint}]: Handler failed."); - response = new ErrorResponse(ex, 500); - } - } - else - { - _logger.LogWarning($"[{handler.RemoteEndPoint}]: Timed out after 30 seconds."); - response = new MessageResponse("Request timed out", 408); - } - - await handler.RespondAsync(response); - handler.Close(); - }, token); - - _requestPool.Add(wrappedTask); - } - - private async Task AcceptRequestAsync(ISocketHandler handler) - { - ServerRequest? request = handler.Parse(); - if (request == null) return new MessageResponse("Unable to parse request", 401); - - Log.Debug($"Received request '{handler.RawData}' from '{handler.RemoteEndPoint}' ({_requestPool.Count} active request{(_requestPool.Count == 1 ? string.Empty : "s")})"); - IRequest? requestInstance = _requests.CreateNew(request); - if (requestInstance == null) return new MessageResponse("Required fields are missing", 401); - return await requestInstance.ExecuteAsync(); - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - // Stops listening for requests. - await _exit.CancelAsync(); - - // Checks if any requests are still being processed. - _requestPool.RemoveAll(c => c.IsCompleted); - if (_requestPool.Count > 0) _logger.LogInformation($"Shutdown received. Still processing {_requestPool.Count} request{(_requestPool.Count == 1 ? string.Empty : "s")}!"); - await Task.WhenAll(_requestPool); - - return base.StopAsync(cancellationToken); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/Utils/UdpReporting.cs b/Parallel.Service/Utils/UdpReporting.cs deleted file mode 100644 index 3d10d00..0000000 --- a/Parallel.Service/Utils/UdpReporting.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Diagnostics; -using Parallel.Core.Models; -using Parallel.Core.Net; - -namespace Parallel.Service.Utils -{ - public class UdpReporting : IProgressReporter - { - private readonly Communication _comms = new Communication(); - - public void Report(ProgressOperation operation, SystemFile file, int current, int total) - { - int percent = current * 100 / total; - //_comms.Send($"[{percent}%] {operation}: {file.LocalPath}"); - } - - public void Failed(Exception exception, SystemFile file) - { - //_comms.Send($"Failed to upload file: '{file.LocalPath}'"); - } - } -} \ No newline at end of file diff --git a/Parallel.Service/parallel-red.ico b/Parallel.Service/parallel-red.ico deleted file mode 100644 index 27a395dd3aeaddc603993a067aef2d4f5e755748..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180837 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

tYWpos4X*=^( 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 Date: Mon, 1 Sep 2025 15:50:02 -0500 Subject: [PATCH 14/14] Removed service from sln --- Parallel.sln | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Parallel.sln b/Parallel.sln index f5b080f..329e4be 100644 --- a/Parallel.sln +++ b/Parallel.sln @@ -7,10 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Core", "Parallel.C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Cli", "Parallel.Cli\Parallel.Cli.csproj", "{4BFE65E9-9534-4C85-B59F-1F64A998C76D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Core.Net", "Parallel.Core.Net\Parallel.Core.Net.csproj", "{157A0A8F-A393-4577-AD3B-DF5FB49A7331}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Service", "Parallel.Service\Parallel.Service.csproj", "{DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -25,14 +21,6 @@ Global {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Debug|Any CPU.Build.0 = Debug|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Release|Any CPU.ActiveCfg = Release|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Release|Any CPU.Build.0 = Release|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Debug|Any CPU.Build.0 = Debug|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Release|Any CPU.ActiveCfg = Release|Any CPU - {157A0A8F-A393-4577-AD3B-DF5FB49A7331}.Release|Any CPU.Build.0 = Release|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE6A8D71-E2A1-4BB5-9BCD-13839D3100AC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE