diff --git a/.gitignore b/.gitignore index 9a9d1e7..56650c3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ *.userosscache *.sln.docstates *.sln +*.lnk # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -397,4 +398,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea/ \ No newline at end of file +.idea/ + +# Batch Script Outputs +Builds/ \ No newline at end of file diff --git a/Build-Release.bat b/Build-Release.bat new file mode 100644 index 0000000..d53b3ef --- /dev/null +++ b/Build-Release.bat @@ -0,0 +1,13 @@ +@echo off + +set SCRIPT_DIR=%~dp0 +set BUILDS_DIR="%SCRIPT_DIR%Builds" + +echo Building to folder: %BUILDS_DIR% +rd /s /q "%BUILDS_DIR%" + +echo Building Parallel.Cli... +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r win-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\win-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r osx-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\osx-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-x64 -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\linux-x64" +CALL dotnet publish "%SCRIPT_DIR%Parallel.Cli\Parallel.Cli.csproj" -r linux-arm -c Release /p:PublishSingleFile=true /p:PublishDir="%BUILDS_DIR%\Parallel.Cli\linux-arm" \ No newline at end of file diff --git a/Parallel.Cli/Commands/CleanCommand.cs b/Parallel.Cli/Commands/CleanCommand.cs new file mode 100644 index 0000000..6b5cb35 --- /dev/null +++ b/Parallel.Cli/Commands/CleanCommand.cs @@ -0,0 +1,126 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Cli.Commands +{ + public class CleanCommand : Command + { + private long _freedBytes = 0; + private int _filesCount = 0; + private int _dirsCount = 0; + + private readonly Option _sourceOpt = new(["--path", "-p"], "The source path to clean."); + private readonly Option _daysOpt = new(["--days", "-d"], "The amount of days to hang onto files."); + private readonly Option _recursiveOpt = new(["--recursive", "-R"], "If to include subdirectories."); + private readonly Option _verboseOpt = new(["--verbose", "-v"], "Shows verbose output."); + + public CleanCommand() : base("clean", "Cleans up the file system by removing old files.") + { + this.AddOption(_sourceOpt); + this.AddOption(_daysOpt); + this.AddOption(_recursiveOpt); + this.AddOption(_verboseOpt); + this.SetHandler(async (path, days, recursive, verbose) => + { + ParallelConfig config = ParallelConfig.Load(); + if (days <= config.RetentionPeriod) days = config.RetentionPeriod; + + if (string.IsNullOrEmpty(path)) + { + await CleanSystemAsync(config, days, recursive, verbose); + } + else + { + await CleanDirectoryAsync(config, path, days, recursive, verbose); + } + + CommandLine.WriteLine($"Successfully cleaned {_filesCount:N0} files and {_dirsCount:N0} directories, ({Formatter.FromBytes(_freedBytes)} removed)", ConsoleColor.Green); + }, _sourceOpt, _daysOpt, _recursiveOpt, _verboseOpt); + } + + private async Task CleanSystemAsync(ParallelConfig config, int days, bool recursive, bool verbose) + { + await System.Threading.Tasks.Parallel.ForEachAsync(config.CleanDirectories, ParallelConfig.Options, async (path, ct) => + { + await CleanDirectoryAsync(config, path, days, recursive, verbose); + }); + } + + private async Task CleanDirectoryAsync(ParallelConfig config, string path, int days, bool recursive, bool verbose) + { + CommandLine.WriteLine($"Scanning for cleanable files older than {days:N0} days old in {path}...", ConsoleColor.DarkGray); + if (!Directory.Exists(path)) + { + CommandLine.WriteLine($"The provided path was not found!", ConsoleColor.Yellow); + return; + } + + UnixTime minTime = UnixTime.FromMilliseconds(UnixTime.Now.TotalMilliseconds - (days * UnixTime.Day)); + IEnumerable cleanableFiles = FileScanner.GetCleanableFiles(path, minTime, recursive); + if (!cleanableFiles.Any()) CommandLine.WriteLine($"No cleanable files were found in the provided path.", ConsoleColor.Green); + + await System.Threading.Tasks.Parallel.ForEachAsync(cleanableFiles, ParallelConfig.Options, (fi, ct) => + { + if (fi.Exists) + { + try + { + _freedBytes += fi.Length; + _filesCount++; + fi.Delete(); + } + catch (Exception ex) + { + CommandLine.WriteLine($"Unable to remove file: {fi.FullName}", ConsoleColor.Yellow); + Log.Warning($"{ex.GetBaseException().Message}"); + } + } + + return ValueTask.CompletedTask; + }); + + IEnumerable directories = FileScanner.GetEmptyDirectories(path, recursive); + if (!directories.Any()) CommandLine.WriteLine($"No empty directories were found in the provided path.", ConsoleColor.Green); + + await System.Threading.Tasks.Parallel.ForEachAsync(directories, ParallelConfig.Options, (di, ct) => + { + if (di.Exists && !di.EnumerateFiles().Any()) + { + try + { + Log.Debug($"Removing empty directory: {di?.FullName}"); + di?.Delete(true); + } + catch (Exception ex) + { + Log.Error($"{ex.GetBaseException().Message}"); + } + } + + return ValueTask.CompletedTask; + }); + + DirectoryInfo currentDir = new DirectoryInfo(path); + SearchOption option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + if (!currentDir.EnumerateFiles("*", option).Any()) + { + try + { + Log.Debug($"Removing empty directory: {currentDir.FullName}"); + currentDir.Delete(true); + _dirsCount++; + } + catch (Exception ex) + { + Log.Warning($"{ex.GetBaseException().Message}"); + } + } + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/ConfigCommand.cs b/Parallel.Cli/Commands/ConfigCommand.cs deleted file mode 100644 index 661a68d..0000000 --- a/Parallel.Cli/Commands/ConfigCommand.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.CommandLine; -using Parallel.Cli.Utils; -using Parallel.Core.Database; -using Parallel.Core.IO.FileSystem; -using Parallel.Core.Settings; -using Parallel.Core.Utils; - -namespace Parallel.Cli.Commands -{ - public class ConfigCommand : Command - { - private Option configOpt = new(["--config", "-c"], "The profile 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."); - - public ConfigCommand() : base("config", "View or edit the profile configurations.") - { - this.SetHandler(() => - { - //ProfileConfig profile = ProfileConfig.Load(); - CommandLine.WriteLine($"Current profile: '{Program.Settings.Profiles.FirstOrDefault()}'"); - }); - - this.AddCommand(addCmd); - addCmd.SetHandler(() => - { - CommandLine.WriteLine("Creating new database credentials...", ConsoleColor.DarkGray); - DatabaseCredentials dbc = new DatabaseCredentials(); - dbc.Provider = Enum.Parse(CommandLine.ReadString($"Provider ({string.Join(", ", Enum.GetNames(typeof(DatabaseProvider)))})"), true); - if (dbc.Provider == DatabaseProvider.Local) - { - dbc = DatabaseCredentials.Local; - } - else - { - dbc.Address = CommandLine.ReadString("Address"); - dbc.Username = CommandLine.ReadString("Username"); - dbc.Password = CommandLine.ReadPassword("Password"); - dbc.Name = CommandLine.ReadString("Name"); - } - - CommandLine.WriteLine("Creating new file system credentials...", ConsoleColor.DarkGray); - FileSystemCredentials fsc = new FileSystemCredentials(); - fsc.Service = Enum.Parse(CommandLine.ReadString($"Service ({string.Join(", ", Enum.GetNames(typeof(FileService)))})"), true); - if (fsc.Service == FileService.Local) - { - fsc.RootDirectory = CommandLine.ReadString("Root"); - } - else if (fsc.Service == FileService.Cloud) - { - fsc.Address = CommandLine.ReadString("Bucket Name"); - fsc.Username = CommandLine.ReadString("Access Key"); - fsc.Password = CommandLine.ReadPassword("Secret Key"); - } - else - { - fsc.RootDirectory = CommandLine.ReadString("Root"); - fsc.Address = CommandLine.ReadString("Address"); - fsc.Username = CommandLine.ReadString("Username"); - fsc.Password = CommandLine.ReadPassword("Password"); - } - - fsc.Encrypt = CommandLine.ReadBool("Encrypt files? (y/n)", false); - fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); - - string profileName = CommandLine.ReadString("Profile Name"); - ProfileConfig profile = new ProfileConfig(profileName, dbc, fsc); - profile.SaveToFile(); - - CommandLine.WriteLine($"Saved new connection profile: '{profile.Name}'"); - }); - - this.AddCommand(setCmd); - setCmd.SetHandler(() => - { - - }); - } - } -} \ No newline at end of file diff --git a/Parallel.Cli/Commands/DecryptCommand.cs b/Parallel.Cli/Commands/DecryptCommand.cs deleted file mode 100644 index ea28e9c..0000000 --- a/Parallel.Cli/Commands/DecryptCommand.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.CommandLine; -using System.Diagnostics; -using Parallel.Cli.Utils; -using Parallel.Core.Database; -using Parallel.Core.IO; -using Parallel.Core.Models; -using Parallel.Core.Settings; -using Parallel.Core.Utils; - -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 IDatabase? _database; - private Stopwatch _sw = new Stopwatch(); - private List _tasks = new List(); - private int _totalTasks = 0; - - 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) - { - CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow); - return; - } - - _database = DatabaseConnection.CreateNew(profile); - string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); - if (PathBuilder.IsDirectory(path)) - { - await DecryptDirectoryAsync(path, masterKey); - } - else if (PathBuilder.IsFile(path)) - { - CommandLine.WriteLine($"Decrypting {path}...", ConsoleColor.DarkGray); - await DecryptFileAsync(path, masterKey); - - CommandLine.WriteLine($"Successfully decrypted file: {path}", ConsoleColor.Green); - } - else - { - CommandLine.WriteLine("The specified path is invalid.", ConsoleColor.Red); - } - }, _sourceArg, _configOpt); - } - - 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(); - if (files.Length == 0) - { - CommandLine.WriteLine("No files found to decrypt!", ConsoleColor.Yellow); - return; - } - - CommandLine.WriteLine($"Decrypting {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - _totalTasks = files.Length; - _tasks = files.Select(file => Task.Run(async () => - { - await DecryptFileAsync(file, masterKey); - CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); - })).ToList(); - - await Task.WhenAll(_tasks); - CommandLine.WriteLine($"Successfully decrypted {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); - } - - private async Task DecryptFileAsync(string path, string masterKey) - { - SystemFile systemFile = await _database?.GetFileAsync(path)! ?? new SystemFile(path); - if (File.Exists(systemFile.LocalPath) && systemFile.Encrypted) - { - string tempFile = Path.Combine(PathBuilder.TempDirectory, Path.GetFileName(systemFile.LocalPath)) + ".tmp"; - await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate)) - { - systemFile.Encrypted = false; - - Encryption.DecryptStream(openFile, createFile, masterKey, systemFile.LastWrite, systemFile.Salt, systemFile.IV); - if (!await _database?.AddFileAsync(systemFile)!) - { - CommandLine.WriteLine($"Failed to decrypt file: {systemFile.LocalPath}", ConsoleColor.Red); - if(File.Exists(tempFile)) File.Delete(tempFile); - return; - } - } - - File.Copy(tempFile, systemFile.LocalPath, true); - if(File.Exists(tempFile)) File.Delete(tempFile); - } - } - } -} \ No newline at end of file diff --git a/Parallel.Cli/Commands/DiskCommand.cs b/Parallel.Cli/Commands/DiskCommand.cs new file mode 100644 index 0000000..df4e46f --- /dev/null +++ b/Parallel.Cli/Commands/DiskCommand.cs @@ -0,0 +1,74 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using System.Data; +using Newtonsoft.Json.Linq; +using Parallel.Cli.Utils; +using Parallel.Core.Database; +using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Cli.Commands +{ + public class DiskCommand : Command + { + private readonly Argument configArg = new("config", "The vault configuration to use."); + + public DiskCommand() : base("disk", "Shows the current disk usage.") + { + this.AddArgument(configArg); + this.SetHandler(async (vault) => + { + CommandLine.WriteLine($"Retrieving vault information...", ConsoleColor.DarkGray); + LocalVaultConfig? config = ParallelConfig.GetVault(vault); + if (config == null) + { + CommandLine.WriteLine($"Unable to find vault with name: '{vault}'", ConsoleColor.Yellow); + return; + } + + await DisplayDiskInformationAsync(config); + }, configArg); + } + + private async Task DisplayDiskInformationAsync(LocalVaultConfig vault) + { + ISyncManager syncManager = SyncManager.CreateNew(vault); + if (!await syncManager.ConnectAsync()) + { + CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); + return; + } + + IDatabase db = syncManager.Database; + long localSize = await db.GetLocalSizeAsync(); + long remoteSize = await db.GetRemoteSizeAsync(); + long totalLocalFiles = await db.GetTotalFilesAsync(false); + long totalDeletedFiles = await db.GetTotalFilesAsync(true); + + CommandLine.WriteLine($"Using vault '{vault.Name}' ({vault.Id}):"); + CommandLine.WriteLine($"Service Type: {vault.FileSystem.Service}"); + CommandLine.WriteLine($"Root Directory: {vault.FileSystem.RootDirectory}"); + CommandLine.WriteLine($"Managed Files: {(totalLocalFiles + totalDeletedFiles):N0}"); + CommandLine.WriteLine($"Local Files: {totalLocalFiles:N0}"); + CommandLine.WriteLine($"Deleted Files: {totalDeletedFiles:N0}"); + CommandLine.WriteLine($"Local Size: {Formatter.FromBytes(localSize)}"); + CommandLine.WriteLine($"Remote Size: {Formatter.FromBytes(remoteSize)}"); + CommandLine.WriteLine($"Space Saved: {Math.Round((localSize - remoteSize) / (double)localSize * 100, 2)}%"); + + if (vault.FileSystem.Service.Equals(FileService.Local)) + { + DriveInfo drive = new(vault.FileSystem.RootDirectory); + long diskUsage = drive.TotalSize - drive.TotalFreeSpace; + CommandLine.WriteLine($"Total Usage: {Formatter.FromBytes(diskUsage)} ({Math.Round(diskUsage / (double)drive.TotalSize * 100, 1)}%)"); + CommandLine.WriteLine($"Disk Usage: {Formatter.FromBytes(diskUsage - remoteSize)} ({Math.Round((diskUsage - remoteSize) / (double)drive.TotalSize * 100, 1)}%)"); + CommandLine.WriteLine($"Disk Free: {Formatter.FromBytes(drive.TotalFreeSpace)} ({Math.Round(drive.TotalFreeSpace / (double)drive.TotalSize * 100, 1)}%)"); + CommandLine.WriteLine($"Disk Total: {Formatter.FromBytes(drive.TotalSize)}"); + } + + await syncManager.DisconnectAsync(); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/DuplicatesCommand.cs b/Parallel.Cli/Commands/DuplicatesCommand.cs index 4d5e2aa..d5f1393 100644 --- a/Parallel.Cli/Commands/DuplicatesCommand.cs +++ b/Parallel.Cli/Commands/DuplicatesCommand.cs @@ -2,11 +2,10 @@ using System.CommandLine; using Parallel.Cli.Utils; -using Parallel.Core.IO.Backup; using Parallel.Core.IO.Scanning; using Parallel.Core.Models; using Parallel.Core.Settings; - +using Parallel.Core.Utils; using TextWriter = Parallel.Cli.Utils.TextWriter; namespace Parallel.Cli.Commands @@ -14,40 +13,21 @@ 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()); long length = duplicates.Sum(kv => kv.Value.Sum(l => l.LocalSize)); - CommandLine.WriteLine($"Scan found {duplicates.Where(kv => kv.Value.Length > 1).Count().ToString("N0")} duplicate files. ({Formatter.FromBytes(length)})"); + CommandLine.WriteLine($"Scan found {duplicates.Count(kv => kv.Value.Length > 1):N0} duplicate files. ({Formatter.FromBytes(length)})"); CommandLine.WriteLine($"A detailed version was created here: {TextWriter.CreateTxtFile(JsonConvert.SerializeObject(result, Formatting.Indented))}"); } } diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs deleted file mode 100644 index dd048c4..0000000 --- a/Parallel.Cli/Commands/EncryptCommand.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.CommandLine; -using System.Diagnostics; -using System.IO.Compression; -using Parallel.Cli.Utils; -using Parallel.Core.Database; -using Parallel.Core.IO; -using Parallel.Core.Models; -using Parallel.Core.Settings; -using Parallel.Core.Utils; - -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 IDatabase? _database; - private Stopwatch _sw = new Stopwatch(); - private List _tasks = new List(); - private int _totalTasks = 0; - - public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") - { - this.AddArgument(_sourceArg); - this.SetHandler(async (path, config) => - { - _sw = Stopwatch.StartNew(); - ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config); - if (profile == null) - { - CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow); - return; - } - - _database = DatabaseConnection.CreateNew(profile); - string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!"); - if (PathBuilder.IsDirectory(path)) - { - await EncryptDirectoryAsync(path, masterKey); - } - else if (PathBuilder.IsFile(path)) - { - CommandLine.WriteLine($"Encrypting {path}...", ConsoleColor.DarkGray); - await EncryptFileAsync(path, masterKey); - - CommandLine.WriteLine($"Successfully encrypted file: {path}", ConsoleColor.Green); - } - else - { - CommandLine.WriteLine("The specified path is invalid.", ConsoleColor.Red); - } - - }, _sourceArg, _configOpt); - } - - 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(); - if (files.Length == 0) - { - CommandLine.WriteLine("No files found to encrypt!", ConsoleColor.Yellow); - return; - } - - CommandLine.WriteLine($"Encrypting {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); - _totalTasks = files.Length; - _tasks = files.Select(file => Task.Run(async () => - { - await EncryptFileAsync(file, masterKey); - CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); - })).ToList(); - - await Task.WhenAll(_tasks); - CommandLine.WriteLine($"Successfully encrypted {files.Length.ToString("N0")} files in {_sw.Elapsed}.", ConsoleColor.Green); - } - - private async Task EncryptFileAsync(string path, string masterKey) - { - SystemFile systemFile = await _database?.GetFileAsync(path)! ?? new SystemFile(path); - if (File.Exists(systemFile.LocalPath) && !systemFile.Encrypted) - { - string tempFile = Path.Combine(PathBuilder.TempDirectory, Path.GetFileName(systemFile.LocalPath)) + ".tmp"; - 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.Encrypted = true; - - Encryption.EncryptStream(openFile, createFile, masterKey, systemFile.LastWrite, systemFile.Salt, systemFile.IV); - if (!await _database?.AddFileAsync(systemFile)!) - { - CommandLine.WriteLine($"Failed to encrypt file: {systemFile.LocalPath}", ConsoleColor.Red); - if(File.Exists(tempFile)) File.Delete(tempFile); - return; - } - } - - File.Copy(tempFile, systemFile.LocalPath, true); - if(File.Exists(tempFile)) File.Delete(tempFile); - } - } - } -} \ 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..fe71931 --- /dev/null +++ b/Parallel.Cli/Commands/PullCommand.cs @@ -0,0 +1,93 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.Diagnostics; +using Parallel.Core.IO; +using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Cli.Commands +{ + public class PullCommand : Command + { + private readonly Option _sourceArg = new(["--path", "-p"], "The source path to sync."); + private readonly Option _configOpt = new(["--config", "-c"], "The vault configuration to use."); + private readonly Option _forceOpt = new(["--force", "-f"], "Forces the pull overwriting any files."); + + public PullCommand() : base("pull", "Pulls changes from a vault.") + { + this.AddOption(_sourceArg); + this.AddOption(_configOpt); + this.AddOption(_forceOpt); + this.SetHandler(async (path, config, force) => + { + LocalVaultConfig? vault = ParallelConfig.GetVault(config); + if (vault == null) + { + CommandLine.WriteLine($"Unable to find vault with name: '{vault}'", ConsoleColor.Yellow); + return; + } + + await PullPathAsync(vault, path, force); + }, _sourceArg, _configOpt, _forceOpt); + } + + private async Task PullPathAsync(LocalVaultConfig vault, string path, bool force) + { + ISyncManager syncManager = SyncManager.CreateNew(vault); + if (!await syncManager.ConnectAsync()) + { + CommandLine.WriteLine(vault, $"Failed to connect to vault '{vault.Name}'!", ConsoleColor.Red); + return; + } + + string fullPath = Path.GetFullPath(path); + if (PathBuilder.IsFile(fullPath)) + { + await PullFileAsync(syncManager, fullPath, force); + return; + } + + IEnumerable files = await syncManager.Database.GetFilesAsync(fullPath); + if (!files.Any()) + { + CommandLine.WriteLine("The provided directory has not been pushed!", ConsoleColor.Yellow); + return; + } + + List pullFiles = new List(); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + if (!File.Exists(file.LocalPath) || FileScanner.HasChanged(file, new SystemFile(file.LocalPath)) || force) pullFiles.Add(file); + }); + + Log.Debug($"Pulling {pullFiles.Count} files..."); + await syncManager.PullFilesAsync(pullFiles.ToArray(), new ProgressLogger()); + CommandLine.WriteLine(vault, $"Successfully pulled {pullFiles.Count:N0} files from '{vault.FileSystem.RootDirectory}'.", ConsoleColor.Green); + } + + private async Task PullFileAsync(ISyncManager syncManager, string fullPath, bool force) + { + SystemFile? remoteFile = await syncManager.Database.GetFileAsync(fullPath); + if (remoteFile == null) + { + CommandLine.WriteLine("The provided file has not been pushed!", ConsoleColor.Yellow); + return; + } + + SystemFile localFile = new SystemFile(fullPath); + if (!(FileScanner.HasChanged(localFile, remoteFile) || force)) + { + CommandLine.WriteLine("Cannot overwrite an existing file!", ConsoleColor.Yellow); + return; + } + + Log.Debug($"Pulling '{fullPath}'"); + await syncManager.PullFilesAsync([remoteFile], new ProgressLogger()); + CommandLine.WriteLine(syncManager.RemoteVault, $"Successfully pulled file from '{syncManager.RemoteVault.FileSystem.RootDirectory}'.", ConsoleColor.Green); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/PushCommand.cs b/Parallel.Cli/Commands/PushCommand.cs new file mode 100644 index 0000000..f5d388d --- /dev/null +++ b/Parallel.Cli/Commands/PushCommand.cs @@ -0,0 +1,96 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.Diagnostics; +using Parallel.Core.IO; +using Parallel.Core.IO.Scanning; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Cli.Commands +{ + public class PushCommand : Command + { + private Command addCmd = new("add", "Adds a new directory to the sync list."); + private Command listCmd = new("list", "Shows all directories in the sync list."); + private Command removeCmd = new("remove", "Removes a directory from the sync list."); + + private readonly Option _sourceArg = new(["--path", "-p"], "The source path to sync."); + 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 changed files to vaults.") + { + this.AddOption(_sourceArg); + this.AddOption(_configOpt); + this.AddOption(_verboseOpt); + this.SetHandler(async (path, config, verbose) => + { + if (string.IsNullOrEmpty(path)) + { + await SyncSystemAsync(); + } + else + { + await SyncPathAsync(path); + } + + }, _sourceArg, _configOpt, _verboseOpt); + } + + private Task SyncSystemAsync() + { + throw new NotImplementedException(); + } + + private async Task SyncPathAsync(string path) + { + await Program.Settings.ForEachVaultAsync(async vault => + { + ISyncManager syncManager = SyncManager.CreateNew(vault); + if (!await syncManager.ConnectAsync()) + { + 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 = syncManager.RemoteVault.BackupDirectories.ToArray(); + string[] ignoredFolders = syncManager.RemoteVault.IgnoreDirectories.ToArray(); + + bool isFile = PathBuilder.IsFile(fullPath); + if (!backupFolders.Any(dir => fullPath.StartsWith(dir, StringComparison.OrdinalIgnoreCase))) + { + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is not set to be backed up!", ConsoleColor.Yellow); + return; + } + + if (FileScanner.IsIgnored(fullPath, ignoredFolders)) + { + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is set to be ignored!", ConsoleColor.Yellow); + return; + } + + CommandLine.WriteLine(vault, $"Scanning for file changes in {path}...", ConsoleColor.DarkGray); + FileScanner scanner = new FileScanner(syncManager); + SystemFile[] files = await scanner.GetFileChangesAsync(path, ignoredFolders); + int successFiles = files.Length; + if (successFiles == 0) + { + CommandLine.WriteLine(vault, $"The provided {(isFile ? "file" : "folder")} is already up to date.", ConsoleColor.Green); + await syncManager.DisconnectAsync(); + return; + } + + CommandLine.WriteLine(vault, $"Backing up {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + await syncManager.PushFilesAsync(files, new ProgressReport(vault, successFiles)); + await syncManager.DisconnectAsync(); + + CommandLine.WriteLine(vault, $"Successfully pushed {successFiles.ToString("N0")} files to '{vault.FileSystem.RootDirectory}'.", ConsoleColor.Green); + }); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/RestoreCommand.cs b/Parallel.Cli/Commands/RestoreCommand.cs new file mode 100644 index 0000000..7b55542 --- /dev/null +++ b/Parallel.Cli/Commands/RestoreCommand.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; + +namespace Parallel.Cli.Commands +{ + public class RestoreCommand : Command + { + private Command createCmd = new("create", "Creates a new recovery point current of the system state."); + private Command listCmd = new("list", "Lists all available recovery points."); + private Command restoreCmd = new("restore", "Restores the system state from a previous recovery point."); + private Option credsOpt = new(["--credentials", "-c"], "The file system credentials to use."); + + public RestoreCommand() : base("restore", "Creates or loads a system restore point.") + { + + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/UnzipCommand.cs b/Parallel.Cli/Commands/UnzipCommand.cs index 385bad6..396a646 100644 --- a/Parallel.Cli/Commands/UnzipCommand.cs +++ b/Parallel.Cli/Commands/UnzipCommand.cs @@ -12,7 +12,7 @@ public class UnzipCommand : Command private readonly Argument sourceArg = new("path", "The source path of files to unzip."); private readonly Option keepOpt = new(["--keep", "-k"], "If the original files should be kept."); - private Stopwatch _sw; + private Stopwatch? _sw; private readonly List _tasks = new List(); private int _totalTasks = 0; @@ -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)) @@ -72,7 +58,7 @@ private void DecompressFile(string path, bool keep) } } - CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); + CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw?.Elapsed ?? TimeSpan.Zero, ConsoleColor.DarkGray); } } } \ No newline at end of file diff --git a/Parallel.Cli/Commands/VaultsCommand.cs b/Parallel.Cli/Commands/VaultsCommand.cs new file mode 100644 index 0000000..8ad97f2 --- /dev/null +++ b/Parallel.Cli/Commands/VaultsCommand.cs @@ -0,0 +1,108 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using Parallel.Cli.Utils; +using Parallel.Core.Database; +using Parallel.Core.IO.FileSystem; +using Parallel.Core.IO.Syncing; +using Parallel.Core.Security; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Cli.Commands +{ + public class VaultsCommand : Command + { + private readonly Argument configArg = new("config", "The vault configuration to use."); + private readonly Option configOpt = new(["--config", "-c"], "The vault configuration to use."); + + 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 VaultsCommand() : base("vaults", "View or edit the vaults.") + { + this.SetHandler(() => + { + CommandLine.WriteLine("Active vaults:"); + for (int i = 0; i < Program.Settings.Vaults.Count; i++) + { + LocalVaultConfig vault = Program.Settings.Vaults.ElementAt(i); + CommandLine.WriteLine($"{i + 1}: {vault.Name} ({vault.Id})"); + } + }); + + this.AddCommand(addCmd); + addCmd.SetHandler(() => + { + CommandLine.WriteLine("Creating new storage vault...", ConsoleColor.DarkGray); + FileSystemCredentials fsc = new FileSystemCredentials(); + fsc.Service = Enum.Parse(CommandLine.ReadString($"Service ({string.Join(", ", Enum.GetNames(typeof(FileService)))})"), true); + if (fsc.Service == FileService.Local) + { + fsc.RootDirectory = CommandLine.ReadString("Root"); + } + else if (fsc.Service == FileService.Cloud) + { + fsc.Address = CommandLine.ReadString("Bucket Name"); + fsc.Username = CommandLine.ReadString("Access Key"); + fsc.Password = CommandLine.ReadPassword("Secret Key"); + } + else + { + fsc.RootDirectory = CommandLine.ReadString("Root"); + fsc.Address = CommandLine.ReadString("Address"); + fsc.Username = CommandLine.ReadString("Username"); + fsc.Password = CommandLine.ReadPassword("Password"); + } + + //fsc.Encrypt = CommandLine.ReadBool("Encrypt files? (y/n)", false); + //fsc.EncryptionKey = HashGenerator.GenerateHash(32, true); + + string? profileName = CommandLine.ReadString("Profile Name"); + LocalVaultConfig localVault = new LocalVaultConfig(profileName, fsc); + Program.Settings.Vaults.Add(localVault); + Program.Settings.Save(); + + CommandLine.WriteLine($"Saved new storage vault: '{localVault.Name}' ({localVault.Id})"); + }); + + this.AddCommand(viewCmd); + viewCmd.AddArgument(configArg); + viewCmd.SetHandler(async (vault) => + { + CommandLine.WriteLine($"Retrieving vault information...", ConsoleColor.DarkGray); + LocalVaultConfig? config = ParallelConfig.GetVault(vault); + if (config == null) + { + CommandLine.WriteLine($"Unable to find vault with name: '{vault}'", ConsoleColor.Yellow); + return; + } + + ISyncManager syncManager = SyncManager.CreateNew(config); + if (!await syncManager.ConnectAsync()) + { + CommandLine.WriteLine(config, $"Failed to connect to vault '{config.Name}'!", ConsoleColor.Red); + return; + } + + RemoteVaultConfig remoteVault = syncManager.RemoteVault; + CommandLine.WriteLine($"'{remoteVault.Name}' ({remoteVault.Id}):"); + CommandLine.WriteArray($"Backup Directories", remoteVault.BackupDirectories); + CommandLine.WriteArray($"Ignore Directories", remoteVault.IgnoreDirectories); + CommandLine.WriteArray($"Prune Directories", remoteVault.PruneDirectories); + CommandLine.WriteLine($"Prune Period: {remoteVault.PrunePeriod} days"); + + await syncManager.DisconnectAsync(); + }, configArg); + + this.AddCommand(setCmd); + setCmd.SetHandler(() => + { + + }); + } + } +} \ No newline at end of file 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)) diff --git a/Parallel.Cli/Parallel.Cli.csproj b/Parallel.Cli/Parallel.Cli.csproj index d042abc..fb20e4e 100644 --- a/Parallel.Cli/Parallel.Cli.csproj +++ b/Parallel.Cli/Parallel.Cli.csproj @@ -3,6 +3,7 @@ Exe net9.0 + Debug;Release;Analyze parallel-red.ico enable enable @@ -15,24 +16,42 @@ $(Company) + + $(DefineConstants);TRACE + True + True + + + $(DefineConstants);DEBUG;TRACE + False + True + Full + + + False + True + True + True + + - - - - + + + + - - + + - + - + diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs index b6ed7a0..6aa1da6 100644 --- a/Parallel.Cli/Program.cs +++ b/Parallel.Cli/Program.cs @@ -9,14 +9,18 @@ namespace Parallel.Cli { internal class Program { - internal static ParallelSettings Settings = new ParallelSettings(); + internal static ParallelConfig Settings = new ParallelConfig(); 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(); + Settings = ParallelConfig.Load(); + string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", "latest.txt"); + if (File.Exists(logFile)) File.Delete(logFile); + #if DEBUG + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); + #else + Log.Logger = new LoggerConfiguration().MinimumLevel.Warning().WriteTo.File(logFile).CreateLogger(); + #endif 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 ac5bdf6..3f16a0c 100644 --- a/Parallel.Cli/Utils/CommandLine.cs +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -1,18 +1,22 @@ // Copyright 2025 Kyle Ebbinga using System.Text; +using Parallel.Core.Security; +using Parallel.Core.Settings; using Parallel.Core.Utils; namespace Parallel.Cli.Utils { public class CommandLine { - public static string? ReadString(object value, ConsoleColor color = ConsoleColor.Gray) + private static readonly object _consoleLock = new(); + + 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) @@ -71,11 +75,56 @@ public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) Console.ResetColor(); } + public static void WriteLine(LocalVaultConfig localVault, object value, ConsoleColor color = ConsoleColor.Gray) + { + string baseLog = $"[{localVault.Id}] {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 WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) { - Console.ForegroundColor = color; - 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) @@ -107,5 +156,25 @@ public static void ProgressBar(double part, double total, TimeSpan elapsed, Cons Console.Write($"\r{percentStr} [{progressBar.ToString()}] {remainingStr}"); } + + public static void WriteArray(string message, IEnumerable elements) + { + lock (_consoleLock) + { + string[] array = elements.Order().ToArray(); + if (array.Length > 0) + { + Console.WriteLine($"> {message}:"); + foreach (string item in array) + { + Console.WriteLine($"> - {item}"); + } + } + else + { + Console.WriteLine($"> {message}: 0"); + } + } + } } } \ No newline at end of file diff --git a/Parallel.Cli/Utils/Formatter.cs b/Parallel.Cli/Utils/Formatter.cs deleted file mode 100644 index f00ee78..0000000 --- a/Parallel.Cli/Utils/Formatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -namespace Parallel.Cli.Utils -{ - public class Formatter - { - public static string FromBytes(long bytes) - { - string[] sizeSuffixes = { "Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; - int sizeIndex = 0; - double size = bytes; - - while (size >= 1000 && sizeIndex < sizeSuffixes.Length - 1) - { - sizeIndex++; - size /= 1000; - } - - return $"{size:N2} {sizeSuffixes[sizeIndex]}"; - } - - public static string FromDateTime(DateTime dateTime) - { - return dateTime.ToLocalTime().ToString("MM/dd/yyyy hh:mmtt"); - } - - public static string FromTimeSpan(TimeSpan timeSpan) - { - return $"{timeSpan.Hours:00}:{timeSpan.Minutes:00}:{timeSpan.Seconds:00}.{timeSpan.Milliseconds:N2}"; - } - } -} \ No newline at end of file diff --git a/Parallel.Cli/Utils/ProgressReport.cs b/Parallel.Cli/Utils/ProgressReport.cs index a5e1d49..5fe0409 100644 --- a/Parallel.Cli/Utils/ProgressReport.cs +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -2,20 +2,38 @@ using Parallel.Core.Diagnostics; using Parallel.Core.Models; +using Parallel.Core.Settings; namespace Parallel.Cli.Utils { public class ProgressReport : IProgressReporter { - public void Report(ProgressOperation operation, SystemFile file, int current, int total) + private readonly LocalVaultConfig _localVault; + private int _current; + private int _total; + + public ProgressReport(LocalVaultConfig localVault, int totalFiles) + { + _localVault = localVault; + _current = 0; + _total = totalFiles; + } + + public void Report(ProgressOperation operation, SystemFile file) + { + int percent = _current++ * 100 / _total; + CommandLine.WriteLine($"[{_localVault.Id}] ({percent}%) {operation}: {file.LocalPath}"); + } + + /// + public void Reset() { - int percent = current * 100 / total; - CommandLine.WriteLine($"[{percent}%] {operation}: {file.LocalPath}"); + _current = 0; } public void Failed(Exception exception, SystemFile file) { - CommandLine.WriteLine($"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); + CommandLine.WriteLine(_localVault, $"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); } } } \ No newline at end of file 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.Core/Database/Contexts/SqliteContext.cs b/Parallel.Core/Database/Contexts/SqliteContext.cs index 738fab1..dc74ff8 100644 --- a/Parallel.Core/Database/Contexts/SqliteContext.cs +++ b/Parallel.Core/Database/Contexts/SqliteContext.cs @@ -4,6 +4,7 @@ using System.Data; using System.Diagnostics; using Dapper; +using Parallel.Core.IO; using Parallel.Core.Models; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -13,18 +14,21 @@ namespace Parallel.Core.Database /// public class SqliteContext : IDatabase { - public string FilePath { get; } - public string ProfileId { get; } + private string FilePath { get; } /// /// Initializes a new instance of the class. /// /// /// - public SqliteContext(DatabaseCredentials credentials, string profileId) + public SqliteContext(string filePath) { - FilePath = credentials.Address; - ProfileId = profileId; + FilePath = filePath; + } + + public void Dispose() + { + // TODO release managed resources here } #region Base @@ -32,19 +36,17 @@ public SqliteContext(DatabaseCredentials credentials, string profileId) /// public IDbConnection CreateConnection() { - return new SqliteConnection("Data Source=" + FilePath); + return new SqliteConnection($"Data Source={FilePath};Pooling=false;"); } /// public async Task InitializeAsync() { - Log.Information("Creating local database..."); - File.Create(FilePath).Close(); - File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); + Log.Information("Creating index database..."); 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` (`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, `checksum` TEXT, PRIMARY KEY(`id`));"); + await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`timestamp`));"); } #endregion @@ -55,15 +57,44 @@ 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);"; - 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 (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) VALUES (@Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @CheckSum);"; + return await connection.ExecuteAsync(sql, new { 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.CheckSum }) > 0; + } + + public async Task GetLocalSizeAsync() + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT SUM(localsize) FROM files;"; + return await connection.QuerySingleOrDefaultAsync(sql); + } + + public async Task GetRemoteSizeAsync() + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT SUM(remotesize) FROM files;"; + return await connection.QuerySingleOrDefaultAsync(sql); + } + + public async Task GetTotalFilesAsync(bool deleted) + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT COUNT(*) FROM files WHERE deleted = @deleted;"; + return await connection.QuerySingleOrDefaultAsync(sql, new { deleted }); + } + + /// + public async Task> GetFilesAsync(string path) + { + using IDbConnection connection = CreateConnection(); + string sql = $"SELECT * FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; + return await connection.QueryAsync(sql); } /// 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 deleted = {deleted} ORDER BY lastupdate DESC"; return await connection.QueryAsync(sql); } @@ -71,7 +102,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 (id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, checksum) FROM files WHERE localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC"; return await connection.QuerySingleOrDefaultAsync(sql); } @@ -82,9 +113,23 @@ 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);"; - return await connection.ExecuteAsync(sql, new { ProfileId, Timestamp = UnixTime.Now.TotalMilliseconds, Name = Path.GetFileName(path), Path = path, Type = type }) > 0; + using (IDbConnection connection = CreateConnection()) + { + string sql = @"INSERT OR REPLACE INTO history (timestamp, name, path, type) VALUES(@Timestamp, @Name, @Path, @Type);"; + return await connection.ExecuteAsync(sql, new { 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 diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs deleted file mode 100644 index b2c8998..0000000 --- a/Parallel.Core/Database/DatabaseConnection.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Settings; - -namespace Parallel.Core.Database -{ - /// - /// The supported file service types. - /// - public enum DatabaseProvider - { - Local - } - - /// - /// Represents a way to connect to different . - /// - public class DatabaseConnection - { - public static IDatabase CreateNew(ProfileConfig profile) - { - switch(profile.Database.Provider) - { - default: return null; - - case DatabaseProvider.Local: - IDatabase db = new SqliteContext(profile.Database, profile.Id); - if (!File.Exists(profile.Database.Address)) db.InitializeAsync(); - return db; - } - } - } -} \ No newline at end of file diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs index 594674f..4564eb8 100644 --- a/Parallel.Core/Database/IDatabase.cs +++ b/Parallel.Core/Database/IDatabase.cs @@ -29,31 +29,26 @@ 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 } /// /// An interface for interacting with client data storage. /// - public interface IDatabase + public interface IDatabase : IDisposable { - /// - /// The identifier to the profile for this database. - /// - string ProfileId { get; } - #region Base /// @@ -79,6 +74,10 @@ public interface IDatabase /// True if successful, false otherwise Task AddFileAsync(SystemFile file); + Task GetLocalSizeAsync(); + Task GetRemoteSizeAsync(); + Task GetTotalFilesAsync(bool deleted); + #endregion #region History @@ -91,9 +90,14 @@ 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 b); + Task> GetFilesAsync(string path); + Task> GetFilesAsync(string path, bool deleted); Task GetFileAsync(string path); } } \ No newline at end of file diff --git a/Parallel.Core/Diagnostics/IProgressReporter.cs b/Parallel.Core/Diagnostics/IProgressReporter.cs index a774bd1..943e0da 100644 --- a/Parallel.Core/Diagnostics/IProgressReporter.cs +++ b/Parallel.Core/Diagnostics/IProgressReporter.cs @@ -22,7 +22,12 @@ public interface IProgressReporter /// /// Reports a progress update. /// - void Report(ProgressOperation operation, SystemFile file, int current, int total); + void Report(ProgressOperation operation, SystemFile file); + + /// + /// Resets the ticking. + /// + void Reset(); /// /// Reports a failed update. diff --git a/Parallel.Core/Diagnostics/ProgressDebug.cs b/Parallel.Core/Diagnostics/ProgressLogger.cs similarity index 52% rename from Parallel.Core/Diagnostics/ProgressDebug.cs rename to Parallel.Core/Diagnostics/ProgressLogger.cs index 9ca0b6e..8dd8463 100644 --- a/Parallel.Core/Diagnostics/ProgressDebug.cs +++ b/Parallel.Core/Diagnostics/ProgressLogger.cs @@ -7,30 +7,38 @@ namespace Parallel.Core.Diagnostics /// /// Represents a basic progress report debugger. /// - public class ProgressDebug : IProgressReporter + public class ProgressLogger : IProgressReporter { private ProgressOperation currentOperation; - private int progressPercentage; + private int _percentage; + private int _current; + private int _total; /// - public void Report(ProgressOperation operation, SystemFile file, int current, int total) + public void Report(ProgressOperation operation, SystemFile file) { - int num = (int)(current / (double)total * 100.0 + 0.5); + int num = (int)(_current++ / (double)_total * 100.0 + 0.5); if (currentOperation != operation) { - progressPercentage = -1; + _percentage = -1; currentOperation = operation; } - if (progressPercentage == num || num % 10 != 0) return; - Log.Information($"{operation}: {current} out of {total} ({progressPercentage}%)"); - progressPercentage = num; + if (_percentage == num || num % 10 != 0) return; + Log.Information($"{operation}: {_current} out of {_total} ({_percentage}%)"); + _percentage = num; + } + + /// + public void Reset() + { + _current = 0; } /// public void Failed(Exception exception, SystemFile file) { - Log.Error($"{exception.GetType().FullName}: {exception.Message}. Failed to upload file: '{file.LocalPath}'"); + Log.Error($"{exception.GetType().FullName}: {exception.Message} Failed to upload file: '{file.LocalPath}'"); } } } \ No newline at end of file diff --git a/Parallel.Core/Events/MessageRecievedEventArgs.cs b/Parallel.Core/Events/MessageRecievedEventArgs.cs index a8ae445..aeab27d 100644 --- a/Parallel.Core/Events/MessageRecievedEventArgs.cs +++ b/Parallel.Core/Events/MessageRecievedEventArgs.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using System.Text; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.Events diff --git a/Parallel.Core/IO/Backup/BackupManager.cs b/Parallel.Core/IO/Backup/BackupManager.cs deleted file mode 100644 index 875c06f..0000000 --- a/Parallel.Core/IO/Backup/BackupManager.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json.Linq; -using Parallel.Core.Settings; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Parallel.Core.IO.Backup -{ - /// - /// Represents the way manage s. - /// - public static class BackupManager - { - /// - /// Creates a new instance of an . - /// - /// - /// - public static IBackupManager CreateNew(ProfileConfig profile) - { - return new FileBackupManager(profile); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/BaseFileManager.cs b/Parallel.Core/IO/Backup/BaseFileManager.cs deleted file mode 100644 index 53f9b7e..0000000 --- a/Parallel.Core/IO/Backup/BaseFileManager.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -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 -{ - /// - /// Represents the base way of backing up files to an associated file system. - /// - public abstract class BaseFileManager : IBackupManager - { - /// - public ProfileConfig Profile { get; } - - /// - public IDatabase Database { get; set; } - - /// - public IFileSystem FileSystem { get; set; } - - /// - public string MachineName { get; } = Environment.MachineName; - - /// - public string RootFolder { get; set; } - - /// - /// - /// - /// - public BaseFileManager(ProfileConfig profile) - { - FileSystem = FileSystemManager.CreateNew(profile.FileSystem); - Profile = profile; - } - - /// - public virtual bool Initialize() - { - try - { - Database = DatabaseConnection.CreateNew(Profile); - bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Profile.IgnoreDirectories.Add(Profile.FileSystem.RootDirectory); - if (Profile != null) Profile.SaveToFile(); - return fsInit; - } - catch (Exception ex) - { - Log.Error(ex.GetBaseException().ToString()); - return false; - } - } - - /// - public abstract Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress); - - /// - public abstract Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress); - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/DeltaBackupManager.cs b/Parallel.Core/IO/Backup/DeltaBackupManager.cs deleted file mode 100644 index 20c2084..0000000 --- a/Parallel.Core/IO/Backup/DeltaBackupManager.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Diagnostics; -using Parallel.Core.Models; -using Parallel.Core.Settings; - -namespace Parallel.Core.IO.Backup -{ - /// - /// Represents the way to clone files to an associated file system using file deltas. - /// - public class DeltaBackupManager : BaseFileManager - { - /// - /// Initializes a new instance of the class. - /// - /// - public DeltaBackupManager(ProfileConfig profile) : base(profile) { } - - /// - public override Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress) - { - throw new NotImplementedException(); - } - - /// - public override Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/FileBackupManager.cs b/Parallel.Core/IO/Backup/FileBackupManager.cs deleted file mode 100644 index 4cd4427..0000000 --- a/Parallel.Core/IO/Backup/FileBackupManager.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json.Linq; -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 -{ - /// - /// Represents the way to archive files to an associated file system. - /// - public class FileBackupManager : BaseFileManager - { - private List _tasks = new List(); - private int _totalFiles; - - /// - /// Initializes a new instance of the class. - /// - /// - public FileBackupManager(ProfileConfig profile) : base(profile) { } - - /// - public override async Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress) - { - if (!files.Any()) return; - SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); - Log.Information($"Backing up {backupFiles.Length} files..."); - await FileSystem.UploadFilesAsync(backupFiles, progress); - for (int i = 0; i < files.Length; i++) - { - SystemFile file = files.ElementAt(i); - if (file.Deleted) - { - progress.Report(ProgressOperation.Archiving, file, i, files.Length); - await Database.AddHistoryAsync(file.LocalPath, HistoryType.Archived); - await Database.AddFileAsync(file); - } - else - { - progress.Report(ProgressOperation.Syncing, file, i, files.Length); - SystemFile remote = await FileSystem.GetFileAsync(file.RemotePath); - if (remote is not null) - { - file.RemoteSize = remote.RemoteSize; - await Database.AddHistoryAsync(file.LocalPath, HistoryType.Synced); - await Database.AddFileAsync(file); - } - } - } - } - - /// - public override async Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress) - { - SystemFile[] restoreFiles = files.Where(f => f.Deleted).ToArray(); - - if (!restoreFiles.Any()) return; - await FileSystem.DownloadFilesAsync(restoreFiles, progress); - - for (int i = 0; i < files.Length; i++) - { - SystemFile file = files[i]; - Log.Information($"Restoring file: {file.LocalPath}..."); - file.RemotePath = PathBuilder.Remote(file.LocalPath, Profile.FileSystem); - } - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Blobs/BlobStorage.cs b/Parallel.Core/IO/Blobs/BlobStorage.cs new file mode 100644 index 0000000..630a143 --- /dev/null +++ b/Parallel.Core/IO/Blobs/BlobStorage.cs @@ -0,0 +1,81 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Diagnostics; +using Parallel.Core.Security; + +namespace Parallel.Core.IO.Blobs +{ + /// + /// Represents the way to chunk files into blobs for syncing. + /// + public class BlobStorage + { + /// + /// The size, in bytes, to use for chunks of a file. + /// + public int ChunkSize { get; set; } + + /// + /// Gets the temp directory for storing blobs. + /// + public string TempDirectory { get; set; } + + public BlobStorage(string tempDir, int chunkSize = 4194304) + { + TempDirectory = tempDir; + ChunkSize = chunkSize; + } + + /// + /// Chunks a file into hashes for blob storage. + /// + /// The source path of the file. + /// The destination to send chunked objects to. + /// + /// + public async Task> ChunkFileAsync(string sourcePath, string destPath, IProgressReporter progress) + { + List chunkHashes = new List(); + await using FileStream fs = File.OpenRead(sourcePath); + byte[] buffer = new byte[ChunkSize]; + int bytesRead = 0; + + while ((bytesRead = await fs.ReadAsync(buffer)) > 0) + { + byte[] chunkData = new byte[bytesRead]; + Buffer.BlockCopy(buffer, 0, chunkData, 0, bytesRead); + + string hash = HashGenerator.CreateSHA256(chunkData); + string chunkPath = PathBuilder.GetObjectPath(destPath, hash); + + if(!File.Exists(chunkPath)) await File.WriteAllBytesAsync(chunkPath, chunkData); + chunkHashes.Add(hash); + } + + Log.Debug($"Wrote {chunkHashes.Count} hashes to {destPath}"); + return chunkHashes; + } + + /// + /// Assembles a file from the chunked hashes. + /// + /// + /// The path to the chunked objects' folder. + /// + public async Task AssembleFileAsync(IEnumerable chunkHashes, string sourcePath, string createFilePath) + { + Log.Debug($"Assembling '{createFilePath}' from {chunkHashes.Count()} hashes."); + await using FileStream createStream = File.Create(createFilePath); + foreach (string hash in chunkHashes) + { + string chunkPath = PathBuilder.GetObjectPath(sourcePath, hash); + if(!File.Exists(chunkPath)) throw new FileNotFoundException($"Missing chunk for hash: {hash}"); + + await using FileStream chunkStream = File.OpenRead(chunkPath); + await chunkStream.CopyToAsync(createStream); + } + + await createStream.FlushAsync(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs index 9268564..799f181 100644 --- a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -17,17 +17,20 @@ namespace Parallel.Core.IO.FileSystem /// public class DotNetFileSystem : IFileSystem { - private readonly FileSystemCredentials _credentials; + private readonly LocalVaultConfig _vaultConfig; /// /// Represents an for interacting with physical machine hardware. /// - /// The credentials to log in with. - public DotNetFileSystem(FileSystemCredentials credentials) + /// The vault to use. + public DotNetFileSystem(LocalVaultConfig vaultConfig) { - _credentials = credentials; + _vaultConfig = vaultConfig; } + /// + public void Dispose() { } + /// public Task CreateDirectoryAsync(string path) { @@ -54,110 +57,93 @@ public Task DeleteFileAsync(string path) return Task.CompletedTask; } - /// + /// public async Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) { - if (!files.Any()) return; - for (int i = 0; i < files.Length; i++) + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => { - SystemFile file = files[i]; - Stopwatch sw = new Stopwatch(); - - progress.Report(ProgressOperation.Downloading, file, i, files.Length); - await using FileStream createStream = File.Create(file.LocalPath); await using FileStream openStream = File.OpenRead(file.RemotePath); + await using FileStream createStream = File.Create(file.LocalPath); await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); - await gzipStream.CopyToAsync(createStream); - - Log.Debug($"Downloaded '{file.LocalPath}' in {sw.ElapsedMilliseconds}ms"); - } + await gzipStream.CopyToAsync(createStream, ct); + }); } /// - public Task GetDirectoryNameAsync(string path) + public async Task DownloadFileAsync(string sourcePath, string destinationPath) { - return Task.FromResult(Path.GetDirectoryName(path)); + await using FileStream openStream = File.OpenRead(destinationPath); + await using FileStream createStream = File.Create(sourcePath); + await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream); } - /// - public Task> GetFilesAsync() + /// + public Task ExistsAsync(string path) { - Dictionary files = new Dictionary(); - foreach (string file in Directory.GetFiles(PathBuilder.RootDirectory(_credentials), "*.gz", SearchOption.AllDirectories)) - { - FileInfo fi = new(file); - files.Add(fi.FullName, new SystemFile(file) - { - Name = fi.Name, - RemotePath = fi.FullName, - LastWrite = new UnixTime(fi.LastWriteTime), - RemoteSize = fi.Length - }); - } - - return Task.FromResult(files); + return Task.FromResult(Directory.Exists(path) || File.Exists(path)); } /// - public Task GetFilesAsync(string path) + public Task GetFileAsync(string path) { - List list = new(); - foreach (string file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - FileInfo fi = new(file); - list.Add(new SystemFile(file) - { - Name = fi.Name, - RemotePath = fi.FullName, - RemoteSize = fi.Length - }); - } - - return Task.FromResult(list.ToArray()); - } + if(!File.Exists(path)) return Task.FromResult(null); - /// - public Task GetFileAsync(string path) - { FileInfo fi = new(path); - return Task.FromResult(new SystemFile(path) + SystemFile file = new SystemFile(path) { Name = fi.Name, RemotePath = fi.FullName, RemoteSize = fi.Length - }); + }; + return Task.FromResult(file); } /// - public Task PingAsync() + public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - Stopwatch sw = Stopwatch.StartNew(); - if (!Directory.Exists(PathBuilder.RootDirectory(_credentials))) return Task.FromResult(-1); - return Task.FromResult(sw.ElapsedMilliseconds); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + try + { + if (await ExistsAsync(file.RemotePath)) File.SetAttributes(file.RemotePath, ~FileAttributes.ReadOnly & File.GetAttributes(file.RemotePath)); + string? parent = Path.GetDirectoryName(file.RemotePath); + if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); + + progress.Report(ProgressOperation.Uploading, file); + await using FileStream openStream = File.OpenRead(file.LocalPath); + await using FileStream createStream = File.Create(file.RemotePath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream, ct); + + File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); + } + catch (Exception ex) + { + Log.Error(ex.GetBaseException().ToString()); + } + }); } - /// - public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) + /// + public async Task UploadFileAsync(string sourcePath, string destinationPath) { - if (!files.Any()) return; - for (int i = 0; i < files.Length; i++) + try { - Stopwatch sw = new Stopwatch(); - SystemFile file = files[i]; - file.RemotePath = PathBuilder.Remote(file.LocalPath, _credentials); - - 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); + if (await ExistsAsync(destinationPath)) File.SetAttributes(destinationPath, ~FileAttributes.ReadOnly & File.GetAttributes(destinationPath)); + string? parent = Path.GetDirectoryName(destinationPath); + if (parent != null && !Directory.Exists(parent)) Directory.CreateDirectory(parent); - await using FileStream createStream = File.Create(file.RemotePath); - await using FileStream openStream = File.OpenRead(file.LocalPath); + await using FileStream openStream = File.OpenRead(sourcePath); + await using FileStream createStream = File.Create(destinationPath); await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); await openStream.CopyToAsync(gzipStream); - Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); - File.SetAttributes(file.RemotePath, File.GetAttributes(file.RemotePath) | FileAttributes.ReadOnly); + File.SetAttributes(destinationPath, File.GetAttributes(destinationPath) | FileAttributes.ReadOnly); + } + catch (Exception ex) + { + Log.Error(ex.GetBaseException().ToString()); } } } diff --git a/Parallel.Core/IO/FileSystem/FileSystemManager.cs b/Parallel.Core/IO/FileSystem/FileSystemManager.cs index 97fa7b5..adb410f 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(LocalVaultConfig vaultConfig) { - return credentials?.Service switch + return vaultConfig.FileSystem.Service switch { - FileService.Local => new DotNetFileSystem(credentials), - FileService.Remote => new SftpFileSystem(credentials), + FileService.Local => new DotNetFileSystem(vaultConfig), + FileService.Remote => new SftpFileSystem(vaultConfig), //FileService.Cloud => new AmazonS3FileSystem(credentials), _ => null }; diff --git a/Parallel.Core/IO/FileSystem/IFileSystem.cs b/Parallel.Core/IO/FileSystem/IFileSystem.cs index 7f043da..76ebe7f 100644 --- a/Parallel.Core/IO/FileSystem/IFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/IFileSystem.cs @@ -13,7 +13,7 @@ namespace Parallel.Core.IO.FileSystem /// /// Defines the way for communicating with a file system. /// - public interface IFileSystem + public interface IFileSystem : IDisposable { /// /// Creates all directories and subdirectories in the specified path unless they already exist. @@ -34,50 +34,45 @@ public interface IFileSystem Task DeleteFileAsync(string path); /// - /// Downloads a file from the associated file system. + /// Downloads an array of files from the associated file system. /// /// /// Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress); /// - /// Returns the parent directory name. - /// - /// - /// - Task GetDirectoryNameAsync(string path); - - /// - /// Gets all the files in the backup. + /// Downloads a file from the associated file system. /// - /// A dictionary of s with the key being the backup path adn the value being the associated . - Task> GetFilesAsync(); + /// + /// + Task DownloadFileAsync(string sourcePath, string destinationPath); /// - /// Gets all the files in the current directory. + /// Checks if a path exists on the associated file system. /// /// - /// A read-only collection of s. - Task GetFilesAsync(string path); + /// True if path exists, otherwise false. + Task ExistsAsync(string path); /// /// Gets a file on the associated file system. /// /// /// - Task GetFileAsync(string path); + Task GetFileAsync(string path); /// - /// Pings the remote file system. + /// Uploads an array of files to the associated file system. /// - /// The time, in milliseconds, of the database latency. -1 if disconnected. - Task PingAsync(); + /// + /// + Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress); /// /// Uploads a file to the associated file system. /// - /// - /// - Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress); + /// + /// + Task UploadFileAsync(string sourcePath, string destinationPath); } } \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs index 58ec6cd..64ae552 100644 --- a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json.Linq; using Parallel.Core.Diagnostics; using Parallel.Core.Models; +using Parallel.Core.Security; using Parallel.Core.Utils; namespace Parallel.Core.IO.FileSystem @@ -18,185 +19,145 @@ namespace Parallel.Core.IO.FileSystem public class SftpFileSystem : IFileSystem { private readonly ConnectionInfo _connectionInfo; + private readonly SftpClient _client; /// /// 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(LocalVaultConfig localVault) { - Console.WriteLine(JObject.FromObject(credentials)); - _connectionInfo = new ConnectionInfo(credentials.Address, credentials.Username, new PasswordAuthenticationMethod(credentials.Username, Encryption.Decode(credentials.Password))); + _connectionInfo = new ConnectionInfo(localVault.FileSystem.Address, localVault.FileSystem.Username, new PasswordAuthenticationMethod(localVault.FileSystem.Username, Encryption.Decode(localVault.FileSystem.Password))); + _client = new SftpClient(_connectionInfo); + _client.Connect(); + } + + + /// + public void Dispose() + { + if (_client.IsConnected) _client.Disconnect(); + _client.Dispose(); } /// public async Task CreateDirectoryAsync(string path) { - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (_client.IsConnected) { - sftp.Connect(); - if (sftp.IsConnected) + string parentDir = string.Empty; + foreach (string subPath in path.Split('/')) { - string parentDir = string.Empty; - foreach (string subPath in path.Split('/')) + parentDir += $"/{subPath}"; + if (!await _client.ExistsAsync(parentDir)) { - parentDir += $"/{subPath}"; - if (!await sftp.ExistsAsync(parentDir)) - { - await sftp.CreateDirectoryAsync(parentDir); - } + await _client.CreateDirectoryAsync(parentDir); } } - - sftp.Disconnect(); } } /// public async Task DeleteDirectoryAsync(string path) { - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (await ExistsAsync(path)) { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - await sftp.DeleteDirectoryAsync(path); - } - - sftp.Disconnect(); + await _client.DeleteDirectoryAsync(path); } } /// public async Task DeleteFileAsync(string path) { - using (SftpClient sftp = new SftpClient(_connectionInfo)) + if (await ExistsAsync(path)) { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - await sftp.DeleteAsync(path); - } - - sftp.Disconnect(); + await _client.DeleteAsync(path); } } - public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) - { - throw new NotImplementedException(); - } - - public Task GetDirectoryNameAsync(string path) + /// + public async Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) { - throw new NotImplementedException(); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + await using SftpFileStream openStream = _client.OpenRead(file.RemotePath); + await using FileStream createStream = File.Create(file.LocalPath); + await using GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream, ct); + }); } - public Task> GetFilesAsync() + /// + public Task DownloadFileAsync(string sourcePath, string destinationPath) { throw new NotImplementedException(); } - /// - public async Task GetFilesAsync(string path) + /// + public async Task ExistsAsync(string path) { - List list = new(); - using (SftpClient sftp = new SftpClient(_connectionInfo)) - { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - foreach (ISftpFile file in sftp.ListDirectory(path)) - { - list.Add(new SystemFile(file.FullName) - { - Name = file.Name, - RemotePath = file.FullName, - RemoteSize = file.Length - }); - } - } - - sftp.Disconnect(); - } - - return list.ToArray(); + return _client.IsConnected && await _client.ExistsAsync(path); } /// - public async Task GetFileAsync(string path) - { - SystemFile file = new SystemFile(path); - using (SftpClient sftp = new SftpClient(_connectionInfo)) - { - sftp.Connect(); - if (sftp.IsConnected && await sftp.ExistsAsync(path)) - { - ISftpFile sf = sftp.Get(path); - file = new SystemFile(sf.FullName) - { - Name = sf.Name, - RemotePath = sf.FullName, - RemoteSize = sf.Length, - }; - } - - sftp.Disconnect(); - } - - return file; - } - - /// - public async Task PingAsync() + public async Task GetFileAsync(string path) { - CancellationTokenSource cts = new(); - Stopwatch sw = Stopwatch.StartNew(); - using (SftpClient sftp = new SftpClient(_connectionInfo)) - { - await sftp.ConnectAsync(cts.Token); - if (!sftp.IsConnected) return -1; - sftp.Disconnect(); - } + if (!await ExistsAsync(path)) return null; - return sw.ElapsedMilliseconds; + ISftpFile sf = _client.Get(path); + return new SystemFile(sf.Name, sf.FullName, sf.Length, sf.LastWriteTime); } /// public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) { - using SftpClient sftp = new SftpClient(_connectionInfo); - sftp.Connect(); - if (sftp.IsConnected) + foreach (SystemFile file in files) { - for (int i = 0; i < files.Length; i++) + try { - SystemFile file = files[i]; Stopwatch sw = new Stopwatch(); - progress.Report(ProgressOperation.Uploading, file, i, files.Length); - if (await sftp.ExistsAsync(file.RemotePath)) sftp.ChangePermissions(file.RemotePath, 644); + progress.Report(ProgressOperation.Uploading, file); + if (await _client.ExistsAsync(file.RemotePath)) _client.ChangePermissions(file.RemotePath, 644); - string parentDir = string.Empty; - foreach (string subPath in file.RemotePath.Split('/')) - { - parentDir += $"/{subPath}"; - if (!await sftp.ExistsAsync(parentDir)) - { - await sftp.CreateDirectoryAsync(parentDir); - } - } + string[] subDirs = file.RemotePath.Split('/'); + string parentDir = string.Join("/", subDirs.Take(subDirs.Length - 1)); + if(!await _client.ExistsAsync(parentDir)) await CreateDirectoryAsync(parentDir); - await using SftpFileStream createStream = sftp.Create(file.RemotePath); + await using SftpFileStream createStream = _client.Create(file.RemotePath); await using FileStream openStream = File.OpenRead(file.LocalPath); await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); await openStream.CopyToAsync(gzipStream); - sftp.ChangePermissions(file.RemotePath, 444); + _client.ChangePermissions(file.RemotePath, 444); Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); } + catch (Exception ex) + { + Log.Error(ex.GetBaseException().ToString()); + } + } + } + + /// + public async Task UploadFileAsync(string sourcePath, string destinationPath) + { + if (await ExistsAsync(destinationPath)) _client.ChangePermissions(destinationPath, 644); + + string parentDir = string.Empty; + foreach (string subPath in destinationPath.Split('/')) + { + parentDir += $"/{subPath}"; + if (!await _client.ExistsAsync(parentDir)) + { + await _client.CreateDirectoryAsync(parentDir); + } } - sftp.Disconnect(); + await using SftpFileStream createStream = _client.Create(destinationPath); + await using FileStream openStream = File.OpenRead(sourcePath); + await using GZipStream gzipStream = new GZipStream(createStream, CompressionLevel.SmallestSize); + await openStream.CopyToAsync(gzipStream); + _client.ChangePermissions(destinationPath, 444); } } } \ No newline at end of file diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs index 6833235..b2b12fd 100644 --- a/Parallel.Core/IO/PathBuilder.cs +++ b/Parallel.Core/IO/PathBuilder.cs @@ -1,6 +1,8 @@ // Copyright 2025 Kyle Ebbinga using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; using Parallel.Core.IO.FileSystem; using Parallel.Core.Models; using Parallel.Core.Settings; @@ -9,15 +11,18 @@ namespace Parallel.Core.IO { /// - /// Represents the way to build paths on different operating systems. + /// Represents the way to build paths on different operating systems. This class cannot be inherited. /// public class PathBuilder { + private static readonly Regex DriveLetterRegex = new(@"^[a-zA-Z]:", RegexOptions.Compiled); + public static string TempDirectory { get { - string tempFolder = Path.Combine(Path.GetTempPath(), $"parallel_{UnixTime.Now.TotalSeconds}"); + string tempFolder = Path.Combine(Path.GetTempPath(), "Parallel"); + Log.Debug($"Temp directory: {tempFolder}"); if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); return tempFolder; } @@ -50,37 +55,90 @@ public static string ProgramData } /// - /// Builds the path for the local file system. + /// Combines an array of strings into a path. This differs from by using the string context for combining paths instead of using the path operator environment variable. /// - /// - /// + /// /// - public static string Local(string path, FileSystemCredentials credentials) + public static string Combine(params string[] paths) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName); - string main = path.Replace("/", "\\").Replace(root, string.Empty).Replace(".gz", string.Empty); + ArgumentNullException.ThrowIfNull(paths); + if (paths.Length == 0) return string.Empty; - Console.WriteLine(root); - Console.WriteLine(main); + // Detect context from the first path + bool isWindowsStyle = DriveLetterRegex.IsMatch(paths[0]); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + char separator = isWindowsStyle ? '\\' : '/'; + char altSeparator = isWindowsStyle ? '/' : '\\'; + + StringBuilder sb = new StringBuilder(); + foreach (string p in paths) { - return main.Substring(1, main.Length - 1).Insert(1, ":"); + if (string.IsNullOrWhiteSpace(p)) continue; + + string part = p.Replace(altSeparator, separator); + + if (sb.Length == 0) + { + sb.Append(part.TrimEnd(separator)); + } + else + { + sb.Append(separator); + sb.Append(part.Trim(separator)); + } } - return main.Replace(@"\", "/"); + return sb.ToString(); } - public static string RootDirectory(FileSystemCredentials credentials) + /// + /// Gets the root directory of the vault. + /// + /// + /// + public static string GetRootDirectory(LocalVaultConfig localVault) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName); - Log.Debug($"Root directory: {root}"); - return credentials.Service switch - { - FileService.Local => root, - FileService.Remote => root.Replace('\\', '/'), - _ => null - }; + return Combine(localVault.FileSystem.RootDirectory, "Parallel", localVault.Id); + } + + /// + /// Gets the primary location where files are stored in the vault. + /// + /// + /// + public static string GetFilesDirectory(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "Files"); + } + + /// + /// Gets the location where snapshots are stored in the vault. + /// + /// + /// + public static string GetSnapshotsDirectory(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "Snapshots"); + } + + /// + /// Gets the path to the vault's configuration file. + /// + /// + /// + public static string GetConfigurationFile(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "config.json.gz"); + } + + /// + /// Gets the path to the vault's database file. + /// + /// + /// + public static string GetDatabaseFile(LocalVaultConfig localVault) + { + return Combine(GetRootDirectory(localVault), "index.db.gz"); } /// @@ -89,14 +147,14 @@ public static string RootDirectory(FileSystemCredentials credentials) /// /// /// - public static string Remote(string path, FileSystemCredentials credentials) + public static string Remote(string path, RemoteVaultConfig remoteVaultConfig) { - string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName, path.Replace(":", string.Empty)) + ".gz"; - return credentials.Service switch + string root = Path.Combine(remoteVaultConfig.FileSystem.RootDirectory, "Parallel", remoteVaultConfig.Id, "Files", path.Replace(":", string.Empty)) + ".gz"; + return remoteVaultConfig.FileSystem.Service switch { FileService.Local => root, FileService.Remote => root.Replace('\\', '/'), - _ => null + _ => string.Empty }; } @@ -109,5 +167,11 @@ public static bool IsFile(string path) { return !Directory.Exists(path) && File.Exists(path); } + + public static string GetObjectPath(string basePath, string hash) + { + if (hash.Length < 8) throw new ArgumentException("Hash too short for sharding", nameof(hash)); + return Path.Combine(basePath, hash.Substring(0, 2), hash.Substring(2, 2), hash.Substring(4, 2), hash.Substring(6, 2), hash); + } } } \ No newline at end of file diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs deleted file mode 100644 index ccab8da..0000000 --- a/Parallel.Core/IO/Recovery/RecoveryManager.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Data; -using Parallel.Core.Database; -using Parallel.Core.IO.FileSystem; -using Parallel.Core.Models; -using Parallel.Core.Settings; - -namespace Parallel.Core.IO.Recovery -{ - /// - /// Represents the way to manage recovery points on the system. - /// - public class RecoveryManager - { - private readonly string _dbPath = Path.Combine(PathBuilder.ProgramData, Environment.MachineName + ".db"); - - public IDatabase Database { get; set; } - public IFileSystem FileSystem { get; set; } - public ProfileConfig Profile { get; set; } - public string MachineName { get; } = Environment.MachineName; - public string RootFolder { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// - public RecoveryManager(ProfileConfig profile) - { - Profile = profile; - Database = DatabaseConnection.CreateNew(profile); - FileSystem = FileSystemManager.CreateNew(profile.FileSystem); - } - - public bool Initialize() - { - try - { - Database = DatabaseConnection.CreateNew(Profile); - bool fsInit = (FileSystem != null) && FileSystem.PingAsync().Result >= 0; - Profile.IgnoreDirectories.Add(Profile.FileSystem.RootDirectory); - if (Profile != null) Profile.SaveToFile(); - return fsInit; - } - catch (Exception ex) - { - Log.Error(ex.GetBaseException().ToString()); - return false; - } - } - - /// - /// Loads a to restore the - /// - /// - public void Load(RecoveryPoint recoveryPoint) - { - - } - - /// - /// Saves the current file system state as a . - /// - /// - public RecoveryPoint Save() - { - /*RecoveryPoint rp = new(Profile.BackupDirectories, Profile.IgnoreDirectories); - DataTable dt = Database.GetFiles(); - foreach (DataRow row in dt.Rows) - { - SystemFile lf = new SystemFile(row); - if (lf.Deleted) - { - rp.DeletedFiles.Add(lf); - } - else - { - rp.LocalFiles.Add(lf); - } - } - - return rp;*/ - return new RecoveryPoint(ArraySegment.Empty, ArraySegment.Empty); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Recovery/RecoveryPoint.cs b/Parallel.Core/IO/Recovery/RecoveryPoint.cs deleted file mode 100644 index ea57c4d..0000000 --- a/Parallel.Core/IO/Recovery/RecoveryPoint.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Models; - -namespace Parallel.Core.IO.Recovery -{ - /// - /// Represents a collection of files at an instance of time on the local machine. - /// - public class RecoveryPoint - { - /// - /// The unique identifier. - /// - public string Id { get; } - - /// - /// The time of creation. - /// - public DateTime CreatedAt { get; } - - /// - /// An array of folders to back up. - /// - public string[] BackupFolders { get; } - - /// - /// An array of folders to ignore. - /// - public string[] IgnoreFolders { get; } - - /// - /// A collection of files that exist in the local machine. - /// - public List LocalFiles { get; } - - /// - /// A collection of deleted files that don't exist on the local machine. - /// - public List DeletedFiles { get; } - - /// - /// Initializes a new instance of the class. - /// - public RecoveryPoint(IEnumerable backupFolders, IEnumerable ignoreFolders) - { - Id = Guid.NewGuid().ToString(); - CreatedAt = DateTime.Now; - BackupFolders = backupFolders.ToArray(); - IgnoreFolders = ignoreFolders.ToArray(); - LocalFiles = new List(); - DeletedFiles = new List(); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs index 90374fc..f4a210d 100644 --- a/Parallel.Core/IO/Scanning/FileScanner.cs +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -2,8 +2,10 @@ 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.IO.Syncing; using Parallel.Core.Models; using Parallel.Core.Settings; using Parallel.Core.Utils; @@ -16,19 +18,13 @@ namespace Parallel.Core.IO.Scanning /// public class FileScanner { - private readonly ProfileConfig _profile; + private readonly RemoteVaultConfig _config; private readonly IDatabase _db; - public FileScanner(ProfileConfig profile, IDatabase database) + public FileScanner(ISyncManager syncManager) { - _profile = profile; - _db = database; - } - - public FileScanner(IBackupManager backup) - { - _profile = backup.Profile; - _db = backup.Database; + _config = syncManager.RemoteVault; + _db = syncManager.Database; } /*/// @@ -58,77 +54,67 @@ 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); + await System.Threading.Tasks.Parallel.ForEachAsync(remoteFiles, ParallelConfig.Options, async (remoteFile, ct) => { - // 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)) { - // 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.RemotePath = remoteFile.RemotePath; + 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}"); + localFile.RemotePath = remoteFile.RemotePath; + 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) + await System.Threading.Tasks.Parallel.ForEachAsync(localFiles, ParallelConfig.Options, async (file, ct) => { - 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) { RemotePath = PathBuilder.Remote(file, _config) }); } - } + }); 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(); } + /// + /// Gets if a file has changed. + /// + /// The source file to compare. + /// The target file to compare to. + /// True is success, otherwise false. + public static bool HasChanged(SystemFile sourcePath, SystemFile? targetPath) + { + Console.WriteLine($"{sourcePath.Name}: {targetPath} == null || ({sourcePath.LastWrite.TotalMilliseconds} > {targetPath.LastWrite.TotalMilliseconds} && {!sourcePath.CheckSum.SequenceEqual(targetPath.CheckSum)}"); + + return targetPath == null || (sourcePath.LastWrite.TotalMilliseconds > targetPath.LastWrite.TotalMilliseconds && !sourcePath.CheckSum.SequenceEqual(targetPath.CheckSum)); + } + + /// /// Gets the total size, in bytes, of a directory. /// @@ -157,7 +143,7 @@ public static long GetDirectorySize(string path) /// The root directory to search. /// If it should search recursively. /// An array of empty directories. - public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = true) + public static IEnumerable GetEmptyDirectories(string path, bool recursive = true) { List list = new(); DirectoryInfo directory = new(path); @@ -169,12 +155,13 @@ public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = foreach (DirectoryInfo di in directory.EnumerateDirectories("*", options)) { - Log.Debug($"Checking -> {di.FullName}"); - if (!di.EnumerateFileSystemInfos().Any()) list.Add(di); + IEnumerable files = di.EnumerateFiles("*", options); + Log.Debug($"{files.Count()} files: {di.FullName}"); + if (!files.Any()) list.Add(di); } Log.Debug($"Found {list.Count} empty directories"); - return list.ToArray(); + return list.OrderByDescending(d => d.FullName.Count(c => c == Path.DirectorySeparatorChar)).ToArray(); } /// @@ -185,7 +172,7 @@ public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = /// The time, as a . /// If it should search recursively. /// An array of directories in order of oldest first. - public static DirectoryInfo[] GetCleanableDirectories(string path, UnixTime start, bool recursive = true) + public static IEnumerable GetCleanableDirectories(string path, UnixTime start, bool recursive = true) { Dictionary list = new(); DirectoryInfo directory = new(path); @@ -204,14 +191,14 @@ public static DirectoryInfo[] GetCleanableDirectories(string path, UnixTime star } Log.Debug($"Found {list.Count} cleanable directories"); - return list.OrderBy(d => d.Value).ToDictionary().Keys.ToArray(); + return list.OrderBy(d => d.Value).ToDictionary().Keys; } - public static FileInfo[] GetCleanableFiles(string path, UnixTime start, bool recursive = true) + public static IEnumerable GetCleanableFiles(string path, UnixTime start, bool recursive = true) { Dictionary list = new(); Log.Debug($"Searching '{path}' for files older than {start.ToString("g")}"); - foreach (string file in GetFiles(path, "*")) + foreach (string file in GetFiles(path, [], "*", recursive)) { FileInfo fi = new FileInfo(file); DateTime compare = fi.CreationTime > fi.LastWriteTime ? fi.CreationTime : fi.LastWriteTime; @@ -220,56 +207,59 @@ public static FileInfo[] GetCleanableFiles(string path, UnixTime start, bool rec } Log.Debug($"Found {list.Count} cleanable files"); - return list.OrderBy(d => d.Value).ToDictionary().Keys.ToArray(); + return list.OrderBy(d => d.Value).ToDictionary().Keys; } 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) + public static IEnumerable GetFiles(string root, string[] exempt, string searchPattern = "*", bool recursive = true) { Stack pending = new(); pending.Push(root); - while (pending.Count != 0) + + while (pending.Count > 0) { - string path = pending.Pop(); - IEnumerable next = null; - try - { - if (!IsIgnored(path, exempt)) - { - //Log.Debug($"Searching -> {path}"); - next = Directory.EnumerateFiles(path, searchPattern); - } - // else - // { - // Log.Debug($"Ignored -> {path}"); - // } - } - catch - { - Log.Debug("No file access -> " + path); - } + string current = pending.Pop(); - if (next != null && next.Count() != 0) + if (IsIgnored(current, exempt)) { - foreach (string file in next) yield return file; + Log.Debug($"Ignored -> {current}"); + continue; } + // Get files in current directory + string[] files = []; try { - next = Directory.EnumerateDirectories(path); - foreach (string subdir in next) pending.Push(subdir); + files = Directory.GetFiles(current, searchPattern); } catch { - Log.Debug("No folder access -> " + path); + Log.Debug($"No file access -> {current}"); + } + + foreach (var file in files) yield return file; + if (recursive) + { + string[] subdirs = []; + try + { + subdirs = Directory.GetDirectories(current); + } + catch + { + Log.Debug($"No folder access -> {current}"); + } + + foreach (var dir in subdirs) pending.Push(dir); } } } + /// /// Scans a directory for duplicate files with the same name and size. /// @@ -279,7 +269,7 @@ public static Dictionary GetDuplicateFiles(string path) { Dictionary> dict = new(); IEnumerable files = GetFiles(path, "*"); - foreach (string file in files) + System.Threading.Tasks.Parallel.ForEach(files, ParallelConfig.Options, file => { SystemFile entry = new(file); if (dict.TryGetValue(entry.Name, out List value)) @@ -294,7 +284,7 @@ public static Dictionary GetDuplicateFiles(string path) { dict.Add(entry.Name, new List { entry }); } - } + }); return dict.Where(kv => kv.Value.Count > 1).OrderByDescending(kv => kv.Value.Count).ToDictionary(k => k.Key, v => v.Value.OrderBy(l => l.LastWrite.TotalMilliseconds).ToArray()); } diff --git a/Parallel.Core/IO/Syncing/BaseSyncManager.cs b/Parallel.Core/IO/Syncing/BaseSyncManager.cs new file mode 100644 index 0000000..116060e --- /dev/null +++ b/Parallel.Core/IO/Syncing/BaseSyncManager.cs @@ -0,0 +1,102 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Database; +using Parallel.Core.Diagnostics; +using Parallel.Core.IO.FileSystem; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.Syncing +{ + /// + /// Represents the base functionality for syncing files to an associated file system. + /// + public abstract class BaseSyncManager : ISyncManager + { + protected string TempDirectory = PathBuilder.TempDirectory; + protected string TempConfigFile => Path.Combine(TempDirectory, $"{LocalVault.Id}.json"); + protected string TempDbFile => Path.Combine(TempDirectory, $"{LocalVault.Id}.db"); + + /// + public LocalVaultConfig LocalVault { get; private set; } + + /// + public RemoteVaultConfig RemoteVault { get; private set; } + + /// + public IDatabase Database { get; set; } + + /// + public IFileSystem FileSystem { get; set; } + + /// + /// + /// + /// + public BaseSyncManager(LocalVaultConfig localVault) + { + FileSystem = FileSystemManager.CreateNew(localVault); + LocalVault = localVault; + } + + /// + public async Task ConnectAsync() + { + string root = PathBuilder.GetRootDirectory(LocalVault); + if (!await FileSystem.ExistsAsync(root)) + { + await FileSystem.CreateDirectoryAsync(root); + Log.Debug($"Created root directory: {root}"); + } + + if (!await FileSystem.ExistsAsync(PathBuilder.GetConfigurationFile(LocalVault))) + { + RemoteVault = new RemoteVaultConfig(LocalVault); + RemoteVault.IgnoreDirectories.Add(PathBuilder.GetRootDirectory(LocalVault)); + RemoteVault.Save(TempConfigFile); + + Log.Debug($"Created config file: {TempConfigFile}"); + } + else + { + await FileSystem.DownloadFilesAsync([new SystemFile(TempConfigFile, PathBuilder.GetConfigurationFile(LocalVault))], new ProgressLogger()); + RemoteVaultConfig? config = RemoteVaultConfig.Load(TempConfigFile); + if(config == null) return false; + RemoteVault = config; + + Log.Debug($"Downloaded config file: {TempConfigFile}"); + } + + if (!await FileSystem.ExistsAsync(PathBuilder.GetDatabaseFile(LocalVault))) + { + Database = new SqliteContext(TempDbFile); + await Database.InitializeAsync(); + + Log.Debug($"Create db file: {TempDbFile}"); + } + else + { + await FileSystem.DownloadFilesAsync([new SystemFile(TempDbFile, PathBuilder.GetDatabaseFile(LocalVault))], new ProgressLogger()); + Database = new SqliteContext(TempDbFile); + + Log.Debug($"Downloaded db file: {TempDbFile}"); + } + + return true; + } + + /// + public async Task DisconnectAsync() + { + SystemFile[] tempFiles = [new SystemFile(TempConfigFile, PathBuilder.GetConfigurationFile(LocalVault)), new SystemFile(TempDbFile, PathBuilder.GetDatabaseFile(LocalVault))]; + await FileSystem.UploadFilesAsync(tempFiles, new ProgressLogger()); + FileSystem.Dispose(); + } + + /// + public abstract Task PushFilesAsync(SystemFile[] files, IProgressReporter progress); + + /// + public abstract Task PullFilesAsync(SystemFile[] files, IProgressReporter progress); + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/BlobSyncManager.cs b/Parallel.Core/IO/Syncing/BlobSyncManager.cs new file mode 100644 index 0000000..7ae3b69 --- /dev/null +++ b/Parallel.Core/IO/Syncing/BlobSyncManager.cs @@ -0,0 +1,43 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Reflection.Metadata; +using Parallel.Core.Diagnostics; +using Parallel.Core.IO.Blobs; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.Syncing +{ + /// + /// Represents the way to sync files with content assigned binary objects. + /// + public class BlobSyncManager : BaseSyncManager + { + private readonly BlobStorage _blobStorage; + private string _hashes; + + /// + /// Initializes a new instance of the class. + /// + /// + public BlobSyncManager(LocalVaultConfig localVault) : base(localVault) + { + _blobStorage = new BlobStorage(TempDirectory); + _hashes = Path.Combine(TempDirectory, "Hashes.json"); + } + + /// + public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) + { + IEnumerable hashes = await _blobStorage.ChunkFileAsync(files.First().LocalPath, Path.Combine(TempDirectory, "objects"), new ProgressLogger()); + File.WriteAllText(_hashes, JsonConvert.SerializeObject(hashes, Formatting.Indented)); + } + + /// + public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) + { + IEnumerable? hashes = JsonConvert.DeserializeObject>(await File.ReadAllTextAsync(_hashes)); + await _blobStorage.AssembleFileAsync(hashes, Path.Combine(TempDirectory, "objects"), files.First().LocalPath); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/DeltaSyncManager.cs b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs new file mode 100644 index 0000000..be0b55c --- /dev/null +++ b/Parallel.Core/IO/Syncing/DeltaSyncManager.cs @@ -0,0 +1,32 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Diagnostics; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.Syncing +{ + /// + /// Represents the way to sync files to an associated file system using file deltas. + /// + public class DeltaSyncManager : BaseSyncManager + { + /// + /// Initializes a new instance of the class. + /// + /// + public DeltaSyncManager(RemoteVaultConfig remoteVaultConfig) : base(remoteVaultConfig) { } + + /// + public override Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) + { + throw new NotImplementedException(); + } + + /// + public override Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Syncing/FileSyncManager.cs b/Parallel.Core/IO/Syncing/FileSyncManager.cs new file mode 100644 index 0000000..5b0eabe --- /dev/null +++ b/Parallel.Core/IO/Syncing/FileSyncManager.cs @@ -0,0 +1,58 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Database; +using Parallel.Core.Diagnostics; +using Parallel.Core.Models; +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.Syncing +{ + /// + /// Represents the way to sync whole files to an associated file system. + /// + public class FileSyncManager : BaseSyncManager + { + /// + /// Initializes a new instance of the class. + /// + /// + public FileSyncManager(LocalVaultConfig localVault) : base(localVault) { } + + /// + public override async Task PushFilesAsync(SystemFile[] files, IProgressReporter progress) + { + if (!files.Any()) return; + SystemFile[] backupFiles = files.Where(f => !f.Deleted).ToArray(); + Log.Information($"Backing up {backupFiles.Length} files..."); + await FileSystem.UploadFilesAsync(backupFiles, progress); + + progress.Reset(); + await System.Threading.Tasks.Parallel.ForEachAsync(files, ParallelConfig.Options, async (file, ct) => + { + if (file.Deleted) + { + progress.Report(ProgressOperation.Archiving, file); + await Database.AddHistoryAsync(file.LocalPath, HistoryType.Archived); + await Database.AddFileAsync(file); + } + else + { + //progress.Report(ProgressOperation.Syncing, file); + SystemFile? remote = await FileSystem.GetFileAsync(file.RemotePath); + if (remote is not null) + { + file.RemoteSize = remote.RemoteSize; + await Database.AddHistoryAsync(file.LocalPath, HistoryType.Pushed); + await Database.AddFileAsync(file); + } + } + }); + } + + /// + public override async Task PullFilesAsync(SystemFile[] files, IProgressReporter progress) + { + await FileSystem.DownloadFilesAsync(files, progress); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/IBackupManager.cs b/Parallel.Core/IO/Syncing/ISyncManager.cs similarity index 55% rename from Parallel.Core/IO/Backup/IBackupManager.cs rename to Parallel.Core/IO/Syncing/ISyncManager.cs index d819857..15d1395 100644 --- a/Parallel.Core/IO/Backup/IBackupManager.cs +++ b/Parallel.Core/IO/Syncing/ISyncManager.cs @@ -2,22 +2,26 @@ 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. /// - public interface IBackupManager + public interface ISyncManager { /// - /// The back-up connection profile. + /// Gets the local vault configuration. /// - public ProfileConfig Profile { get; } + public LocalVaultConfig LocalVault { get; } + + /// + /// Gets the remote vault configuration. + /// + public RemoteVaultConfig RemoteVault { get; } /// /// The associated database connection. @@ -30,34 +34,27 @@ public interface IBackupManager IFileSystem FileSystem { get; set; } /// - /// The current machine name. + /// Establishes a connection to the associated and downloads the needed files. /// - string MachineName { get; } - - /// - /// The root directory of the back-up. - /// - string RootFolder { get; set; } - + Task ConnectAsync(); /// - /// Initializes the backup manager by logging into the and + /// Closes the current connection and releases its resources. /// - /// - bool Initialize(); + Task DisconnectAsync(); /// - /// 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/Syncing/SyncManager.cs b/Parallel.Core/IO/Syncing/SyncManager.cs new file mode 100644 index 0000000..9151632 --- /dev/null +++ b/Parallel.Core/IO/Syncing/SyncManager.cs @@ -0,0 +1,22 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.Syncing +{ + /// + /// Represents the way manage s. + /// + public static class SyncManager + { + /// + /// Creates a new instance of an . + /// + /// + /// + public static ISyncManager CreateNew(LocalVaultConfig localVault) + { + return new FileSyncManager(localVault); + } + } +} \ 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/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs index aa4ccdb..9ad23ca 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 @@ -73,19 +74,9 @@ public class SystemFile public bool Deleted { get; set; } = false; /// - /// If the file is encrypted in the backup. + /// The checksum used to check if the file has changed. /// - public bool Encrypted { get; set; } = false; - - /// - /// The salt used to encrypt the file. - /// - public byte[] Salt { get; set; } = Array.Empty(); - - /// - /// The initialization vector used to encrypt the file. - /// - public byte[] IV { get; set; } = Array.Empty(); + public string? CheckSum { get; set; } /// @@ -99,27 +90,41 @@ 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; + CheckSum = HashGenerator.CheckSum(path); + } - if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden)) - { - Hidden = true; - } - - if (fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly)) - { - ReadOnly = true; - } + public SystemFile(string localPath, string remotePath) + { + LocalPath = localPath; + RemotePath = remotePath; } /// /// 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 id, string name, string localpath, string remotepath, long lastwrite, long lastupdate, long localsize, long remotesize, string type, long hidden, long readOnly, long deleted, string checksum) { Id = id; Name = name; @@ -129,15 +134,27 @@ 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 SystemFile(string name, string remotePath, long length, DateTime lastWriteTime) + { + Name = name; + RemotePath = remotePath; + RemoteSize = length; + LastWrite = new UnixTime(lastWriteTime); + } + + //public SystemFile() { } + + /// + /// Determines if this instance and another have the same values. + /// + /// + /// True if equal, otherwise false. public bool Equals(SystemFile value) { bool?[] results = @@ -152,9 +169,7 @@ public bool Equals(SystemFile value) value?.Hidden != null ? this.Hidden.Equals(value.Hidden) : (bool?)null, value?.ReadOnly != null ? this.ReadOnly.Equals(value.ReadOnly) : (bool?)null, value?.Deleted != null ? this.Deleted.Equals(value.Deleted) : (bool?)null, - 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); diff --git a/Parallel.Core/Parallel.Core.csproj b/Parallel.Core/Parallel.Core.csproj index aa8cd3a..fdd0ec4 100644 --- a/Parallel.Core/Parallel.Core.csproj +++ b/Parallel.Core/Parallel.Core.csproj @@ -3,25 +3,26 @@ net9.0 enable + True + Parallel.Core + AnyCPU enable - 1.0.1.0 - Entex Interactive, LLC - Copyright Entex Interactive, LLC. All Rights Reserved. + Parallel.Core + 1.0.0.0 + Kyle Ebbinga + Copyright $(Company). All Rights Reserved. $(AssemblyVersion) $(VersionPrefix)$(AssemblyVersion) $(Company) - Parallel.Core - True - Parallel.Core - - - - - + + + + + @@ -34,8 +35,8 @@ - - + + diff --git a/Parallel.Core/Security/Encryption.cs b/Parallel.Core/Security/Encryption.cs index 029f6fe..d49edcf 100644 --- a/Parallel.Core/Security/Encryption.cs +++ b/Parallel.Core/Security/Encryption.cs @@ -2,9 +2,9 @@ using System.Security.Cryptography; using System.Text; -using Parallel.Core.Models; +using Parallel.Core.Utils; -namespace Parallel.Core.Utils +namespace Parallel.Core.Security { /// /// Provides functionality for encryption. This class cannot be inherited. @@ -46,14 +46,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)) { @@ -69,14 +69,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 43614f4..6fda9c4 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]; @@ -66,6 +71,16 @@ public static string CreateSHA1(string value) return Convert.ToHexString(SHA1.HashData(Encoding.ASCII.GetBytes(value))).ToLower(); } + /// + /// Computes a SHA256 hash from bytes. + /// + /// The string to hash. + /// A hash as a string. + public static string CreateSHA256(byte[] value) + { + return Convert.ToHexString(SHA256.HashData(value)).ToLower(); + } + /// /// Computes a SHA256 hash from a string. /// @@ -76,5 +91,18 @@ public static string CreateSHA256(string value) ArgumentException.ThrowIfNullOrEmpty(value); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLower(); } + + /// + /// + /// + /// + /// + public static string? CheckSum(string path) + { + if (!File.Exists(path)) return null; + using FileStream fs = File.OpenRead(path); + using SHA256 sha256 = SHA256.Create(); + return Convert.ToHexString(sha256.ComputeHash(fs)).ToLowerInvariant(); + } } } \ No newline at end of file diff --git a/Parallel.Core/Settings/DatabaseCredentials.cs b/Parallel.Core/Settings/DatabaseCredentials.cs deleted file mode 100644 index 306c45a..0000000 --- a/Parallel.Core/Settings/DatabaseCredentials.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Parallel.Core.Database; -using Parallel.Core.IO; - -namespace Parallel.Core.Settings -{ - /// - /// Represents credentials used to gain access to various s. - /// - public class DatabaseCredentials - { - /// - /// The associated provider of this database. - /// - public DatabaseProvider Provider { get; set; } = DatabaseProvider.Local; - - /// - /// The hostname or address of the database. - /// If using a , this will be a file path. - /// - public string Address { get; set; } = string.Empty; - - /// - /// The username of the database. - /// - public string Username { get; set; } = string.Empty; - - /// - /// The password of the database. - /// - public string Password { get; set; } = string.Empty; - - /// - /// The database name. - /// - public string Name { get; set; } = string.Empty; - - public static DatabaseCredentials Local => new() - { - Provider = DatabaseProvider.Local, - Address = Path.Combine(PathBuilder.ProgramData, $"{Environment.MachineName}.db") - }; - } -} \ No newline at end of file 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. diff --git a/Parallel.Core/Settings/LocalVaultConfig.cs b/Parallel.Core/Settings/LocalVaultConfig.cs new file mode 100644 index 0000000..76f5652 --- /dev/null +++ b/Parallel.Core/Settings/LocalVaultConfig.cs @@ -0,0 +1,68 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.IO.FileSystem; +using Parallel.Core.Security; + +namespace Parallel.Core.Settings +{ + /// + /// Represents a localized vault connection configuration. + /// + public class LocalVaultConfig + { + /// + /// A unique hash used to identify the vault. + /// + public string Id { get; } + + /// + /// The name of the vault. + /// + public string Name { get; set; } + + /// + /// The credentials needed to log in to the associated . + /// + public FileSystemCredentials FileSystem { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + [JsonConstructor] + public LocalVaultConfig(string id, string name, FileSystemCredentials fileSystem) + { + Id = id; + Name = name; + FileSystem = fileSystem; + } + + public LocalVaultConfig(string name, FileSystemCredentials fileSystem) + { + Id = HashGenerator.GenerateHash(8, true); + Name = name; + FileSystem = fileSystem; + } + + /// + /// Loads settings from a file. + /// + public static LocalVaultConfig? Load(string path) + { + return !File.Exists(path) ? null : JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + + /// + /// Saves credentials to a file. + /// + /// + /// + public static void Save(ParallelConfig config, LocalVaultConfig localVault) + { + config.Vaults.Add(localVault); + config.Save(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Settings/ParallelConfig.cs b/Parallel.Core/Settings/ParallelConfig.cs new file mode 100644 index 0000000..17d382a --- /dev/null +++ b/Parallel.Core/Settings/ParallelConfig.cs @@ -0,0 +1,137 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json; +using Org.BouncyCastle.Math.EC; +using Parallel.Core.IO; + +namespace Parallel.Core.Settings +{ + /// + /// + /// + public class ParallelConfig + { + /// + /// The location to the application configuration file. + /// + private static string ConfigFile { get; } = Path.Combine(PathBuilder.ProgramData, "Configuration.json"); + + /// + /// The location of files for different file system credentials./>. + /// + public static string VaultsDir { get; } = Path.Combine(PathBuilder.ProgramData, "Vaults"); + + public static ParallelOptions Options { get; } = new ParallelOptions + { + MaxDegreeOfParallelism = Load().MaxConcurrentProcesses + }; + + // /// + // /// The address that will accept incoming commands. + // /// Default: 127.0.0.1 + // /// + // public string Address { get; set; } = "127.0.0.1"; + // + // /// + // /// The port number to listen for commands on. + // /// Default: 8192 + // /// + // public int ListenerPort { get; set; } = 8192; + + /// + /// Gets or sets the maximum number of concurrent vaults that can run. + /// Default: 2 + /// + public int MaxConcurrentVaults { get; set; } = 2; + + /// + /// Gets or sets the maximum number of concurrent processes that can run. + /// Default: Half the processor count. + /// + public int MaxConcurrentProcesses { get; set; } = Math.Clamp(Environment.ProcessorCount / 2, 1, Environment.ProcessorCount); + + /// + /// The amount of time, in days, to hold a file before it can be cleaned. + /// Default: 90 days + /// + public int RetentionPeriod { get; set; } = 90; + + /// + /// A collection of directories to be cleaned on the machine. + /// It's important to note that when using the service host this will delete any available file. + /// Default: Empty + /// + public HashSet CleanDirectories { get; } = CreateCleanDirectories(); + + /// + /// The profiles to use. + /// When pulling, the CLI defaults to the first in the list. + /// + public HashSet Vaults { get; } = []; + + + /// + /// Loads settings from a file. + /// + public static ParallelConfig Load() + { + Log.Debug($"Loading config file: {ConfigFile}"); + if (!File.Exists(ConfigFile)) return new ParallelConfig(); + + string json = File.ReadAllText(ConfigFile); + ParallelConfig? config = JsonConvert.DeserializeObject(json); + return config ?? new ParallelConfig(); + } + + /// + /// Saves settings to a file. + /// + public void Save() + { + Log.Debug($"Saving config file: {ConfigFile}"); + if (!Directory.Exists(PathBuilder.ProgramData)) Directory.CreateDirectory(PathBuilder.ProgramData); + File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + + /// + /// Creates a default array of cleanable directories. + /// + /// + private static HashSet CreateCleanDirectories() + { + return + [ + Path.GetTempPath(), + ]; + } + + /// + /// Asynchronously runs an for each using the limiter. + /// + /// + /// + public async Task ForEachVaultAsync(Func actionAsync, CancellationToken cancellationToken = default) + { + ParallelOptions options = new ParallelOptions + { + MaxDegreeOfParallelism = MaxConcurrentVaults, + CancellationToken = cancellationToken + }; + + await System.Threading.Tasks.Parallel.ForEachAsync(Vaults, options, async (vault, ct) => + { + await actionAsync(vault); + }); + } + + /// + /// Gets a by either its id or name. + /// + /// + /// + public static LocalVaultConfig? GetVault(string vault) + { + return Load().Vaults.FirstOrDefault(v => v.Id.Equals(vault, StringComparison.OrdinalIgnoreCase) || v.Name.Equals(vault, StringComparison.OrdinalIgnoreCase)); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs deleted file mode 100644 index 412a8b5..0000000 --- a/Parallel.Core/Settings/ParallelSettings.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using Newtonsoft.Json; -using Org.BouncyCastle.Math.EC; -using Parallel.Core.IO; - -namespace Parallel.Core.Settings -{ - /// - /// - /// - public class ParallelSettings - { - /// - /// The location to the application configuration file. - /// - private static string ConfigFile { get; } = Path.Combine(PathBuilder.ProgramData, "settings.json"); - - /// - /// The location of files for different file system credentials./>. - /// - public static string ProfilesDir { get; } = Path.Combine(PathBuilder.ProgramData, "Profiles"); - - /// - /// The address that will accept incoming commands. - /// Default: 127.0.0.1 - /// - public string Address { get; set; } = "127.0.0.1"; - - /// - /// The port number to listen for commands on. - /// Default: 8192 - /// - public int ListenerPort { get; set; } = 8192; - - /// - /// The profiles to use. - /// The CLI defaults to the first in the list. - /// - public HashSet Profiles { get; } = new HashSet(); - - - /// - /// Loads settings from a file. - /// - public static ParallelSettings Load() - { - Log.Debug($"Loading config file: {ConfigFile}"); - if (File.Exists(ConfigFile)) - { - string json = File.ReadAllText(ConfigFile); - return JsonConvert.DeserializeObject(json); - } - else - { - return new ParallelSettings(); - } - } - - /// - /// Saves settings to a file. - /// - public void Save() - { - Log.Debug($"Saving config file: {ConfigFile}"); - if (!Directory.Exists(PathBuilder.ProgramData)) Directory.CreateDirectory(PathBuilder.ProgramData); - File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(this, Formatting.Indented)); - } - } -} \ No newline at end of file diff --git a/Parallel.Core/Settings/ProfileConfig.cs b/Parallel.Core/Settings/ProfileConfig.cs deleted file mode 100644 index 5878534..0000000 --- a/Parallel.Core/Settings/ProfileConfig.cs +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2025 Kyle Ebbinga - -using System.Runtime.InteropServices; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Parallel.Core.Database; -using Parallel.Core.IO.FileSystem; -using Parallel.Core.Utils; - -namespace Parallel.Core.Settings -{ - /// - /// Represents a back-up connection. - /// - public class ProfileConfig - { - /// - /// A unique hash used to identify the profile. - /// - public string Id { get; } = HashGenerator.GenerateHash(12, true); - - /// - /// The name of the profile. - /// - public string Name { get; set; } = "Default"; - - /// - /// The credentials needed to log in to the associated . - /// - public DatabaseCredentials Database { get; } - - /// - /// The credentials needed to log in to the associated . - /// - public FileSystemCredentials FileSystem { get; } - - /// - /// The amount of time, in minutes, between backup cycles. - /// Default: 60 minutes - /// - public int BackupInterval { get; set; } = 60; - - /// - /// The amount of time, in days, to hold a file before it can be cleaned. - /// Default: 90 days - /// - public int RetentionPeriod { get; set; } = 90; - - /// - /// The amount of time, in days, to hold a file before it can be pruned. - /// Default: 180 days (6 months) - /// - public int PrunePeriod { get; set; } = 180; - - /// - /// A collection of directories to be backed up. - /// Default: Empty - /// - public HashSet BackupDirectories { get; } = CreateBackupDirectories(); - - /// - /// A collection of directories to be ignored when archiving or cleaning. - /// Default: Empty - /// - public HashSet IgnoreDirectories { get; } = CreateIgnoreDirectories(); - - /// - /// A collection of directories to be cleaned on the machine. - /// It's important to note that when using the service host this will delete any available file. - /// Default: Empty - /// - public HashSet CleanDirectories { get; } = CreateCleanDirectories(); - - /// - /// A collection of deleted directories allowed to be pruned. - /// Recommended when using a cloud-based to save on storage costs. - /// Default: Empty - /// - public HashSet PruneDirectories { get; } = new HashSet(); - - - /// - /// Initializes a new instance of the class. - /// - /// - /// - /// - /// - [JsonConstructor] - public ProfileConfig(string id, string name, DatabaseCredentials database, FileSystemCredentials fileSystem) - { - Id = id; - Name = name; - Database = database; - FileSystem = fileSystem; - } - - /// - /// Initializes a new instance of the class. - /// - /// - /// - /// - public ProfileConfig(string name, DatabaseCredentials database, FileSystemCredentials fileSystem) - { - Id = HashGenerator.GenerateHash(12, true); - Name = name; - Database = database; - FileSystem = fileSystem; - } - - /// - /// Loads settings from a file. - /// - 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()); - } - } - - /// - /// Loads credentials from the app configuration. - /// - /// 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; - } - - /// - /// Saves credentials to a file. - /// - /// The current profile to save. - public static void Save(ProfileConfig profile) - { - 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 (!File.Exists(path)) - { - Log.Debug("Creating file -> " + path); - File.Create(path).Close(); - } - - File.WriteAllText(path, JsonConvert.SerializeObject(profile, Formatting.Indented)); - } - - /// - /// Saves the current instance to a file. - /// - public void SaveToFile() - { - Save(this); - } - - #region Privates - - private static HashSet CreateBackupDirectories() - { - return - [ - Environment.GetFolderPath(Environment.SpecialFolder.Desktop), - Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), - Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), - Environment.GetFolderPath(Environment.SpecialFolder.MyVideos) - ]; - } - - private static HashSet CreateIgnoreDirectories() - { - HashSet list = new HashSet(); - - // Ignore folders on Windows machines - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - list.Add("$RECYCLE.BIN/"); // For NTFS file systems - list.Add("*.lnk"); // Shortcuts to other paths - } - - // Ignore folders on Linux machines - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - list.Add("lost+found/"); // File system recovery directory - list.Add(".Trash/"); // User's trash folder - list.Add("*.desktop"); // Linux shortcuts - } - - // Ignore folders on Apple machines - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - list.Add(".Trash/"); // User's trash folder - list.Add("*.DS_Store"); // macOS Finder metadata - } - - return list; - } - - private static HashSet CreateCleanDirectories() - { - return - [ - Path.GetTempPath(), - ]; - } - - #endregion - } -} \ No newline at end of file diff --git a/Parallel.Core/Settings/RemoteVaultConfig.cs b/Parallel.Core/Settings/RemoteVaultConfig.cs new file mode 100644 index 0000000..d31cba8 --- /dev/null +++ b/Parallel.Core/Settings/RemoteVaultConfig.cs @@ -0,0 +1,123 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Runtime.InteropServices; +using Newtonsoft.Json; +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; + +namespace Parallel.Core.Settings +{ + /// + /// Represents the configuration for the vault. + /// + public class RemoteVaultConfig : LocalVaultConfig + { + /// + /// The amount of time, in minutes, between backup cycles. + /// Default: 60 minutes + /// + public int BackupInterval { get; set; } = 60; + + /// + /// The amount of time, in days, to hold a file before it can be pruned. + /// Default: 180 days (6 months) + /// + public int PrunePeriod { get; set; } = 180; + + /// + /// A collection of directories to be backed up. + /// Default: Empty + /// + public HashSet BackupDirectories { get; } = CreateBackupDirectories(); + + /// + /// A collection of directories to be ignored when archiving or cleaning. + /// Default: Empty + /// + public HashSet IgnoreDirectories { get; } = CreateIgnoreDirectories(); + + /// + /// A collection of deleted directories allowed to be pruned. + /// Recommended when using a cloud-based to save on storage costs. + /// Default: Empty + /// + public HashSet PruneDirectories { get; } = []; + + + public RemoteVaultConfig(LocalVaultConfig localVault) : base(localVault.Id, localVault.Name, localVault.FileSystem) { } + + public RemoteVaultConfig(string profileName, FileSystemCredentials fsc) : base(profileName, fsc) { } + + [JsonConstructor] + public RemoteVaultConfig(string id, string name, FileSystemCredentials fileSystem, int backupInterval, int prunePeriod, IEnumerable backupDirectories, IEnumerable ignoreDirectories, IEnumerable pruneDirectories) : base(id, name, fileSystem) + { + BackupInterval = backupInterval; + PrunePeriod = prunePeriod; + BackupDirectories = new HashSet(backupDirectories); + IgnoreDirectories = new HashSet(ignoreDirectories); + PruneDirectories = new HashSet(pruneDirectories); + } + + #region Privates + + private static HashSet CreateBackupDirectories() + { + return + [ + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), + Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), + Environment.GetFolderPath(Environment.SpecialFolder.MyVideos) + ]; + } + + private static HashSet CreateIgnoreDirectories() + { + HashSet list = new HashSet(); + + // Ignore folders on Windows machines + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + list.Add("$RECYCLE.BIN/"); // For NTFS file systems + list.Add("*.lnk"); // Shortcuts to other paths + } + + // Ignore folders on Linux machines + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + list.Add("lost+found/"); // File system recovery directory + list.Add(".Trash/"); // User's trash folder + list.Add("*.desktop"); // Linux shortcuts + } + + // Ignore folders on Apple machines + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + list.Add(".Trash/"); // User's trash folder + list.Add("*.DS_Store"); // macOS Finder metadata + } + + return list; + } + + #endregion + + /// + /// Loads settings from a file. + /// + public new static RemoteVaultConfig? Load(string path) + { + return !File.Exists(path) ? null : JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + + public void Save(string path) + { + File.WriteAllText(path, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + } +} \ 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 27a395d..0000000 Binary files a/Parallel.Service/parallel-red.ico and /dev/null differ diff --git a/Parallel.sln b/Parallel.sln index f5b080f..9ffbb75 100644 --- a/Parallel.sln +++ b/Parallel.sln @@ -7,32 +7,25 @@ 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 Release|Any CPU = Release|Any CPU + Analyze|Any CPU = Analyze|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Debug|Any CPU.Build.0 = Debug|Any CPU {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Release|Any CPU.ActiveCfg = Release|Any CPU {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Release|Any CPU.Build.0 = Release|Any CPU + {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU + {1391ED00-10D5-417A-982B-0A9A6A2295D4}.Analyze|Any CPU.Build.0 = Analyze|Any CPU {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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 + {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Analyze|Any CPU.ActiveCfg = Analyze|Any CPU + {4BFE65E9-9534-4C85-B59F-1F64A998C76D}.Analyze|Any CPU.Build.0 = Analyze|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index e3fc26d..75fe5f7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# [Parallel Icon](https://github.com/TheGuitarleader/Parallel) Parallel +# [Parallel Icon](https://github.com/TheGuitarleader/Parallel) Parallel [![.NET](https://img.shields.io/github/actions/workflow/status/TheGuitarleader/Parallel/dotnet.yml?label=Main%20build&style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/actions/workflows/dotnet.yml) [![latest version](https://img.shields.io/github/v/release/TheGuitarleader/Parallel?label=Latest%20release&style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/releases/latest) [![GitHub Downloads](https://img.shields.io/github/downloads/TheGuitarleader/Parallel/total?style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/releases/latest) @@ -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