diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..0574582 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,27 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/.gitignore b/.gitignore index a4fe18b..9a9d1e7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.user *.userosscache *.sln.docstates +*.sln # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -34,6 +35,7 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ + # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ @@ -82,8 +84,6 @@ StyleCopReport.xml *.pgc *.pgd *.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp *.sbr *.tlb *.tli @@ -95,7 +95,7 @@ StyleCopReport.xml *.tlog *.vspscc *.vssscc -.builds +.build *.pidb *.svclog *.scc @@ -257,7 +257,6 @@ Generated_Code/ # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ -Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ @@ -398,3 +397,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/ \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index cc23bb0..b75b631 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1 +1,2 @@ -Parallel © 2025 by [Entex Interactive, LLC](https://www.entexinteractive.com/) is licensed under [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) +Parallel © 2025 by [Kyle Ebbinga](https://github.com/TheGuitarleader/Parallel) is licensed under [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) + diff --git a/Parallel.Cli/Commands/ConfigCommand.cs b/Parallel.Cli/Commands/ConfigCommand.cs new file mode 100644 index 0000000..6a42351 --- /dev/null +++ b/Parallel.Cli/Commands/ConfigCommand.cs @@ -0,0 +1,84 @@ +// 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 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 new file mode 100644 index 0000000..e031679 --- /dev/null +++ b/Parallel.Cli/Commands/DecryptCommand.cs @@ -0,0 +1,14 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; + +namespace Parallel.Cli.Commands +{ + public class DecryptCommand : Command + { + public DecryptCommand() : base("decrypt", "Decrypts a file or directory.") + { + + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/EncryptCommand.cs b/Parallel.Cli/Commands/EncryptCommand.cs new file mode 100644 index 0000000..f4787b5 --- /dev/null +++ b/Parallel.Cli/Commands/EncryptCommand.cs @@ -0,0 +1,16 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; + +namespace Parallel.Cli.Commands +{ + public class EncryptCommand : Command + { + private readonly Argument sourceArg = new("path", "The source path to encrypt."); + + public EncryptCommand() : base("encrypt", "Encrypts a file or directory.") + { + + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/UnzipCommand.cs b/Parallel.Cli/Commands/UnzipCommand.cs new file mode 100644 index 0000000..385bad6 --- /dev/null +++ b/Parallel.Cli/Commands/UnzipCommand.cs @@ -0,0 +1,78 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using System.Diagnostics; +using System.IO.Compression; +using Parallel.Cli.Utils; + +namespace Parallel.Cli.Commands +{ + 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 readonly List _tasks = new List(); + private int _totalTasks = 0; + + public UnzipCommand() : base("unzip", "Unzips files in a directory.") + { + this.AddArgument(sourceArg); + this.AddOption(keepOpt); + this.SetHandler(async (path, keep) => + { + _sw = Stopwatch.StartNew(); + CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray); + string[] files = Directory.GetFiles(path, $"*.gz", SearchOption.AllDirectories); + if (files.Length == 0) + { + CommandLine.WriteLine("No files found to unzip!", ConsoleColor.Yellow); + return; + } + + CommandLine.WriteLine($"Unzipping {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + _totalTasks = files.Length; + foreach (string file in files) + { + StartDecompressFile(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)) + { + using (FileStream openFile = File.OpenRead(path)) + using (FileStream createFile = new FileStream(path.Replace(".gz", string.Empty), FileMode.OpenOrCreate)) + using (GZipStream gZip = new GZipStream(openFile, CompressionMode.Decompress)) + { + gZip.CopyTo(createFile); + } + + if (!keep) + { + File.SetAttributes(path, ~FileAttributes.ReadOnly & File.GetAttributes(path)); + File.Delete(path); + } + } + + CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Commands/ZipCommand.cs b/Parallel.Cli/Commands/ZipCommand.cs new file mode 100644 index 0000000..3dd1a5d --- /dev/null +++ b/Parallel.Cli/Commands/ZipCommand.cs @@ -0,0 +1,80 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.CompilerServices; +using Parallel.Cli.Utils; +using SQLitePCL; + +namespace Parallel.Cli.Commands +{ + public class ZipCommand : Command + { + private readonly Argument sourceArg = new("path", "The source path of files to zip."); + private readonly Option keepOpt = new(["--keep", "-k"], "If the original files should be kept."); + + private Stopwatch _sw = new Stopwatch(); + private readonly List _tasks = new List(); + private int _totalTasks = 0; + + public ZipCommand() : base("zip", "Zips files in a directory.") + { + this.AddArgument(sourceArg); + this.AddOption(keepOpt); + this.SetHandler(async (path, keep) => + { + _sw = Stopwatch.StartNew(); + 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 zip!", ConsoleColor.Yellow); + return; + } + + CommandLine.WriteLine($"Zipping {files.Length.ToString("N0")} files...", ConsoleColor.DarkGray); + _totalTasks = files.Length; + foreach (string file in files) + { + StartCompressFile(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)) + { + using (FileStream openFile = File.OpenRead(path)) + using (FileStream createFile = new FileStream($"{path}.gz", FileMode.OpenOrCreate)) + using (GZipStream gZip = new GZipStream(createFile, CompressionLevel.SmallestSize)) + { + openFile.CopyTo(gZip); + } + + if (!keep) + { + File.SetAttributes(path, ~FileAttributes.ReadOnly & File.GetAttributes(path)); + File.Delete(path); + } + } + + CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Parallel.Cli.csproj b/Parallel.Cli/Parallel.Cli.csproj new file mode 100644 index 0000000..d042abc --- /dev/null +++ b/Parallel.Cli/Parallel.Cli.csproj @@ -0,0 +1,38 @@ + + + + Exe + net9.0 + parallel-red.ico + enable + enable + Parallel + 1.0.0 + Kyle Ebbinga + Copyright $(Company). All Rights Reserved. + $(AssemblyVersion) + $(VersionPrefix)$(AssemblyVersion) + $(Company) + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Parallel.Cli/Program.cs b/Parallel.Cli/Program.cs new file mode 100644 index 0000000..9d020b4 --- /dev/null +++ b/Parallel.Cli/Program.cs @@ -0,0 +1,31 @@ +// Copyright 2025 Kyle Ebbinga + +using System.CommandLine; +using System.Reflection; +using Parallel.Core.IO; +using Parallel.Core.Settings; + +namespace Parallel.Cli +{ + internal class Program + { + internal static ParallelSettings Settings = new ParallelSettings(); + + public static async Task Main(string[] args) + { + Settings = ParallelSettings.Load(); + string logFile = Path.Combine(PathBuilder.ProgramData, "Logs", $"{DateTime.Now:MM-dd-yyyy hh-mm-ss}.log"); + //Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.File(logFile).CreateLogger(); + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); + + AssemblyName assembly = Assembly.GetExecutingAssembly().GetName(); + Log.Information($"{assembly.Name} [Version {assembly.Version}]"); + + RootCommand rootCommand = new("Parallel file manager - Easily back up and synchronize massive amounts of files, and free up drive space."); + Type[] types = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.IsSubclassOf(typeof(Command)) && t.IsClass).ToArray(); + foreach (Type? type in types) rootCommand.AddCommand((Command)Activator.CreateInstance(type)!); + await rootCommand.InvokeAsync(args); + Settings.Save(); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Utils/CommandLine.cs b/Parallel.Cli/Utils/CommandLine.cs new file mode 100644 index 0000000..ac5bdf6 --- /dev/null +++ b/Parallel.Cli/Utils/CommandLine.cs @@ -0,0 +1,111 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Text; +using Parallel.Core.Utils; + +namespace Parallel.Cli.Utils +{ + public class CommandLine + { + public static string? ReadString(object value, ConsoleColor color = ConsoleColor.Gray) + { + Console.ForegroundColor = color; + Console.Write($"> {value}: "); + Console.ResetColor(); + return Console.ReadLine(); + } + + public static bool ReadBool(object value, bool defaultValue, ConsoleColor color = ConsoleColor.Gray) + { + HashSet trueValues = new HashSet(StringComparer.OrdinalIgnoreCase) { "y", "yes", "true", "1" }; + HashSet falseValues = new HashSet(StringComparer.OrdinalIgnoreCase) { "n", "no", "false", "0" }; + + Console.ForegroundColor = color; + Console.Write($"> {value}: "); + + bool result = defaultValue; + string? input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input) && trueValues.Contains(input)) + { + result = true; + } + + if (!string.IsNullOrEmpty(input) && falseValues.Contains(input)) + { + result = false; + } + + Console.ResetColor(); + return result; + } + + public static string? ReadPassword(object value, ConsoleColor color = ConsoleColor.Gray) + { + string password = string.Empty; + Console.ForegroundColor = color; + Console.Write($"> {value}: "); + Console.ResetColor(); + ConsoleKeyInfo key; + + do + { + key = Console.ReadKey(true); + if (key.Key != ConsoleKey.Enter && key.Key != ConsoleKey.Backspace) + { + password += key.KeyChar; + } + else if (key.Key == ConsoleKey.Backspace && password.Length > 0) + { + password = password.Substring(0, password.Length - 1); + } + } while (key.Key != ConsoleKey.Enter); + + Console.WriteLine(); + return Encryption.Encode(password); + } + + public static void Write(object value, ConsoleColor color = ConsoleColor.Gray) + { + Console.ForegroundColor = color; + Console.Write("\r" + value?.ToString()?.PadRight(Console.WindowWidth)); + Console.ResetColor(); + } + + public static void WriteLine(object value, ConsoleColor color = ConsoleColor.Gray) + { + Console.ForegroundColor = color; + Console.WriteLine($"> {value}"); + Console.ResetColor(); + } + + public static void ProgressBar(double part, double total, TimeSpan elapsed, ConsoleColor color = ConsoleColor.Gray) + { + double percent = part / total; + string percentStr = $"> Progress: {Convert.ToInt32(percent * 100).ToString("D2")}%"; + + TimeSpan remaining; + double remainingMs = elapsed.TotalMilliseconds * (total - part) / part; + if (remainingMs <= TimeSpan.MaxValue.TotalMilliseconds) + remaining = TimeSpan.FromMilliseconds(remainingMs); + else + remaining = TimeSpan.MaxValue; + + string remainingStr = $"{remaining.Hours:00}:{remaining.Minutes:00}:{remaining.Seconds:00} remaining"; + int barWidth = Console.WindowWidth - percentStr.Length - remainingStr.Length - 4; + int filledWidth = Convert.ToInt32(percent * barWidth); + + StringBuilder progressBar = new StringBuilder(barWidth); + for (int i = 0; i < filledWidth; i++) + { + progressBar.Append('#'); + } + + for (int i = filledWidth; i < barWidth; i++) + { + progressBar.Append('.'); + } + + Console.Write($"\r{percentStr} [{progressBar.ToString()}] {remainingStr}"); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Utils/Formatter.cs b/Parallel.Cli/Utils/Formatter.cs new file mode 100644 index 0000000..f00ee78 --- /dev/null +++ b/Parallel.Cli/Utils/Formatter.cs @@ -0,0 +1,32 @@ +// 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 new file mode 100644 index 0000000..a5e1d49 --- /dev/null +++ b/Parallel.Cli/Utils/ProgressReport.cs @@ -0,0 +1,21 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Diagnostics; +using Parallel.Core.Models; + +namespace Parallel.Cli.Utils +{ + public class ProgressReport : IProgressReporter + { + public void Report(ProgressOperation operation, SystemFile file, int current, int total) + { + int percent = current * 100 / total; + CommandLine.WriteLine($"[{percent}%] {operation}: {file.LocalPath}"); + } + + public void Failed(Exception exception, SystemFile file) + { + CommandLine.WriteLine($"Failed to upload file: '{file.LocalPath}'", ConsoleColor.Red); + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/Utils/TextWriter.cs b/Parallel.Cli/Utils/TextWriter.cs new file mode 100644 index 0000000..01848bc --- /dev/null +++ b/Parallel.Cli/Utils/TextWriter.cs @@ -0,0 +1,52 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Parallel.Cli.Utils +{ + public class TextWriter + { + public static string CreateTxtFile(string text) + { + string parentDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "Parallel"); + if (!Directory.Exists(parentDir)) Directory.CreateDirectory(parentDir); + + string fileName = Path.Combine(parentDir, DateTime.Now.ToString("MM-dd-yyyy hh-mm-ss") + ".json"); + File.WriteAllText(fileName, text); + return fileName; + } + + public static string CreateTxtFile(params string[] lines) + { + string parentDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "Parallel"); + if (!Directory.Exists(parentDir)) Directory.CreateDirectory(parentDir); + + string fileName = Path.Combine(parentDir, DateTime.Now.ToString("MM-dd-yyyy hh-mm-ss") + ".txt"); + File.WriteAllLines(fileName, lines); + return fileName; + } + + public static string CreateJsonFile(params string[] lines) + { + string parentDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "Parallel"); + if (!Directory.Exists(parentDir)) Directory.CreateDirectory(parentDir); + + string fileName = Path.Combine(parentDir, DateTime.Now.ToString("MM-dd-yyyy hh-mm-ss") + ".json"); + JArray json = JArray.FromObject(lines); + + File.WriteAllText(fileName, JsonConvert.SerializeObject(json, Newtonsoft.Json.Formatting.Indented)); + return fileName; + } + + public static string CreateJsonFile(JArray json) + { + string parentDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "Parallel"); + if (!Directory.Exists(parentDir)) Directory.CreateDirectory(parentDir); + + string fileName = Path.Combine(parentDir, DateTime.Now.ToString("MM-dd-yyyy hh-mm-ss") + ".json"); + File.WriteAllText(fileName, JsonConvert.SerializeObject(json, Newtonsoft.Json.Formatting.Indented)); + return fileName; + } + } +} \ No newline at end of file diff --git a/Parallel.Cli/parallel-red.ico b/Parallel.Cli/parallel-red.ico new file mode 100644 index 0000000..27a395d Binary files /dev/null and b/Parallel.Cli/parallel-red.ico differ diff --git a/Parallel.Core.Net/Connections/IConnection.cs b/Parallel.Core.Net/Connections/IConnection.cs new file mode 100644 index 0000000..b87b4e4 --- /dev/null +++ b/Parallel.Core.Net/Connections/IConnection.cs @@ -0,0 +1,10 @@ +// 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 new file mode 100644 index 0000000..7e75d9c --- /dev/null +++ b/Parallel.Core.Net/Connections/TcpConnection.cs @@ -0,0 +1,88 @@ +// 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/Parallel.Core.Net.csproj b/Parallel.Core.Net/Parallel.Core.Net.csproj new file mode 100644 index 0000000..be6daf4 --- /dev/null +++ b/Parallel.Core.Net/Parallel.Core.Net.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/Parallel.Core.Net/ServerRequest.cs b/Parallel.Core.Net/ServerRequest.cs new file mode 100644 index 0000000..932865c --- /dev/null +++ b/Parallel.Core.Net/ServerRequest.cs @@ -0,0 +1,31 @@ +// 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 new file mode 100644 index 0000000..78e90a9 --- /dev/null +++ b/Parallel.Core.Net/ServerResponse.cs @@ -0,0 +1,30 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json.Linq; + +namespace Parallel.Core.Net.Connections +{ + public class ServerResponse + { + public ServerRequest Request { get; } + public bool IsSuccess { get; } = false; + public JToken? Data { get; } + + public ServerResponse(ServerRequest request) + { + Request = request; + } + + private ServerResponse(ServerRequest request, JToken? data, bool isSuccess) + { + Request = request; + IsSuccess = isSuccess; + Data = data; + } + + public static ServerResponse Parse(ServerRequest request, JToken? json) + { + return new ServerResponse(request, json, json != null); + } + } +} \ No newline at end of file diff --git a/Parallel.Core.Net/Sockets/ISocketHandler.cs b/Parallel.Core.Net/Sockets/ISocketHandler.cs new file mode 100644 index 0000000..08398c3 --- /dev/null +++ b/Parallel.Core.Net/Sockets/ISocketHandler.cs @@ -0,0 +1,42 @@ +// 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 new file mode 100644 index 0000000..de68a70 --- /dev/null +++ b/Parallel.Core.Net/Sockets/TcpSocketHandler.cs @@ -0,0 +1,82 @@ +// 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/Data/FileTypes.cs b/Parallel.Core/Data/FileTypes.cs new file mode 100644 index 0000000..919047f --- /dev/null +++ b/Parallel.Core/Data/FileTypes.cs @@ -0,0 +1,165 @@ +// Copyright 2025 Kyle Ebbinga + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Parallel.Core.Data +{ + /// + /// The type of file. + /// + public enum FileCategory + { + Document, + Photo, + Music, + Video, + Other + }; + + /// + /// Represents file types. This class cannot be inherited. + /// + public static class FileTypes + { + /// + /// Gets the category of a file based off the extension. + /// + /// + /// + public static FileCategory GetFileCategory(string extension) + { + return extension.ToLower() switch + { + // Documents + ".asp" => FileCategory.Document, + ".aspx" => FileCategory.Document, + ".bak" => FileCategory.Document, + ".c" => FileCategory.Document, + ".cab" => FileCategory.Document, + ".cer" => FileCategory.Document, + ".cfg" => FileCategory.Document, + ".cfm" => FileCategory.Document, + ".cgi" => FileCategory.Document, + ".class" => FileCategory.Document, + ".cpl" => FileCategory.Document, + ".cpp" => FileCategory.Document, + ".cs" => FileCategory.Document, + ".css" => FileCategory.Document, + ".csv" => FileCategory.Document, + ".cur" => FileCategory.Document, + ".dat" => FileCategory.Document, + ".db" => FileCategory.Document, + ".dbf" => FileCategory.Document, + ".dll" => FileCategory.Document, + ".dmp" => FileCategory.Document, + ".doc" => FileCategory.Document, + ".docx" => FileCategory.Document, + ".drv" => FileCategory.Document, + ".fnt" => FileCategory.Document, + ".fon" => FileCategory.Document, + ".h" => FileCategory.Document, + ".htm" => FileCategory.Document, + ".html" => FileCategory.Document, + ".icns" => FileCategory.Document, + ".ico" => FileCategory.Document, + ".ini" => FileCategory.Document, + ".lnk" => FileCategory.Document, + ".java" => FileCategory.Document, + ".jar" => FileCategory.Document, + ".js" => FileCategory.Document, + ".json" => FileCategory.Document, + ".jsp" => FileCategory.Document, + ".log" => FileCategory.Document, + ".mdb" => FileCategory.Document, + ".msi" => FileCategory.Document, + ".odt" => FileCategory.Document, + ".otf" => FileCategory.Document, + ".part" => FileCategory.Document, + ".pdf" => FileCategory.Document, + ".php" => FileCategory.Document, + ".pl" => FileCategory.Document, + ".ppt" => FileCategory.Document, + ".pptx" => FileCategory.Document, + ".py" => FileCategory.Document, + ".rss" => FileCategory.Document, + ".sav" => FileCategory.Document, + ".sh" => FileCategory.Document, + ".sql" => FileCategory.Document, + ".swift" => FileCategory.Document, + ".sys" => FileCategory.Document, + ".tar" => FileCategory.Document, + ".tmp" => FileCategory.Document, + ".ttf" => FileCategory.Document, + ".txt" => FileCategory.Document, + ".vb" => FileCategory.Document, + ".xhtml" => FileCategory.Document, + ".xls" => FileCategory.Document, + ".xlsx" => FileCategory.Document, + ".xml" => FileCategory.Document, + ".zip" => FileCategory.Document, + + // Photos + ".tif" => FileCategory.Photo, + ".tiff" => FileCategory.Photo, + ".bmp" => FileCategory.Photo, + ".jpg" => FileCategory.Photo, + ".jpeg" => FileCategory.Photo, + ".png" => FileCategory.Photo, + ".eps" => FileCategory.Photo, + ".raw" => FileCategory.Photo, + ".arw" => FileCategory.Photo, + ".svg" => FileCategory.Photo, + + // Music + ".m4a" => FileCategory.Music, + ".flac" => FileCategory.Music, + ".mp3" => FileCategory.Music, + ".wav" => FileCategory.Music, + ".wma" => FileCategory.Music, + ".aac" => FileCategory.Music, + + // Video + ".mp4" => FileCategory.Video, + ".mov" => FileCategory.Video, + ".wmv" => FileCategory.Video, + ".avi" => FileCategory.Video, + ".avchd" => FileCategory.Video, + ".flv" => FileCategory.Video, + ".f4v" => FileCategory.Video, + ".webm" => FileCategory.Video, + + _ => FileCategory.Other + }; + } + + public static FileCategory FromString(string type) + { + return type switch + { + "Document" => FileCategory.Document, + "Photo" => FileCategory.Photo, + "Music" => FileCategory.Music, + "Video" => FileCategory.Video, + + _ => FileCategory.Other + }; + } + + public static string ToString(FileCategory type) + { + return type switch + { + FileCategory.Document => "Document", + FileCategory.Photo => "Photo", + FileCategory.Music => "Music", + FileCategory.Video => "Video", + + _ => "Other" + }; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Database/DatabaseConnection.cs b/Parallel.Core/Database/DatabaseConnection.cs new file mode 100644 index 0000000..692e249 --- /dev/null +++ b/Parallel.Core/Database/DatabaseConnection.cs @@ -0,0 +1,33 @@ +// 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 SqliteDatabase(profile.Database, profile.Id); + if (!File.Exists(profile.Database.Address)) db.Initialize(); + return db; + } + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Database/IDatabase.cs b/Parallel.Core/Database/IDatabase.cs new file mode 100644 index 0000000..96572c6 --- /dev/null +++ b/Parallel.Core/Database/IDatabase.cs @@ -0,0 +1,157 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.IO; +using System; +using System.Data; +using Parallel.Core.IO.FileSystem; +using Parallel.Core.Models; + +namespace Parallel.Core.Database +{ + /// + /// The type of history to log. + /// + public enum HistoryType + { + /// + /// A file that has been deleted locally but is still backed up. + /// + Archived, + + /// + /// A file that has been deleted locally. + /// + Cleaned, + + /// + /// A file that has been copied to another location. + /// + Cloned, + + /// + /// A file that has been deleted from the backup. + /// + Pruned, + + /// + /// A file that was deleted and has been restored. + /// + Restored, + + /// + /// A newly synced file. + /// + Synced + } + + /// + /// An interface for interacting with client data storage. + /// + public interface IDatabase + { + string ProfileId { get; } + + #region Base + + /// + /// Called when initializing a new instance. + /// Used for creating new table schemas. + /// + void Initialize(); + + /// + /// Runs a query in the assigned database language. + /// + /// The query to run. + /// The id of the last inserted row. -1 if none exist. + int RunQuery(string query); + + /// + /// Gets data from a query in the assigned database language and converts it to a . + /// + /// The query to send to the database. + /// A filled with returned data. + DataTable GetQuery(string query); + + /// + /// Pings the database server. + /// + /// The time, in milliseconds, of the database latency. -1 if disconnected. + long Ping(); + + #endregion + + #region Files + + /// + /// Adds a new file or updates an existing one. + /// + /// + /// The id of the last inserted row. -1 if none exist. + bool AddFile(SystemFile file); + + /// + /// Removes a file. + /// + /// + void RemoveFile(SystemFile file); + + /// + /// Gets a of all files in order of most recent. + /// + DataTable GetFiles(); + + /// + /// Gets a of files of either local files or deleted files in order of most recent. + /// + /// true if the table should only be deleted files, otherwise false + DataTable GetFiles(bool deleted); + + /// + /// Gets a of files of either local files or deleted files by a query in order of most recent. + /// + /// The query to search local file paths for. + /// true if the table should only be deleted files, otherwise false + DataTable GetFiles(string query, bool deleted); + + /// + /// Gets a of files by their last update milliseconds in order of oldest first. + /// + /// The lowest millisecond value to look for. + /// True if the table should only be deleted files, otherwise false + /// The total amount of entries to return. + DataTable GetFiles(long milliseconds, bool deleted, int limit); + + /// + /// Gets a of either local files or deleted files from a query and last update milliseconds in order of oldest first. + /// + /// The path to search local deleted files for. + /// The lowest millisecond value to look for. + /// true if the table should only be deleted files, otherwise false + /// The total amount of entries to return. + DataTable GetFiles(string query, long milliseconds, bool deleted, int limit); + + /// + /// Gets a specific file in the database. + /// + /// The file path. Either local or on in the backup. + /// + SystemFile GetFile(string query); + + long GetLocalSize(); + long GetRemoteSize(); + int GetTotalFiles(bool deleted); + + #endregion + + #region History + + int AddHistory(string path, HistoryType type); + + DataTable GetHistory(string path, int limit); + + DataTable GetHistory(string path, HistoryType type, int limit); + + #endregion + } +} \ No newline at end of file diff --git a/Parallel.Core/Database/SqliteDatabase.cs b/Parallel.Core/Database/SqliteDatabase.cs new file mode 100644 index 0000000..15738ec --- /dev/null +++ b/Parallel.Core/Database/SqliteDatabase.cs @@ -0,0 +1,214 @@ +// Copyright 2025 Kyle Ebbinga + +using Microsoft.Data.Sqlite; +using System.Data; +using System.Diagnostics; +using Parallel.Core.Models; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Core.Database +{ + /// + public class SqliteDatabase : IDatabase + { + public string FilePath { get; } + public string ProfileId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public SqliteDatabase(DatabaseCredentials credentials, string profileId) + { + FilePath = credentials.Address; + ProfileId = profileId; + } + + #region Base + + /// + public void Initialize() + { + Log.Information("Creating local database..."); + File.Create(FilePath).Close(); + File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden); + + // Create tables + RunQuery( + "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, PRIMARY KEY(`profile`, `id`));"); + + RunQuery("CREATE TABLE IF NOT EXISTS `history` (`profile` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `id` TEXT NOT NULL, `path` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`profile`, `timestamp`));"); + } + + /// + public DataTable GetQuery(string query) + { + DataTable dt = new(); + Log.Debug($"Getting SQL query: {query}"); + using (SqliteConnection sqlite = new("Data Source=" + FilePath)) + using (SqliteCommand cmd = new(query, sqlite)) + { + sqlite.Open(); + using (SqliteDataReader reader = cmd.ExecuteReader()) + { + dt.Load(reader); + } + + sqlite.Close(); + sqlite.Dispose(); + } + + return dt; + } + + /// + public int RunQuery(string query) + { + Log.Debug($"Running SQL query: {query}"); + using (SqliteConnection sqlite = new("Data Source=" + FilePath)) + { + sqlite.Open(); + using (SqliteCommand cmd = sqlite.CreateCommand()) + { + cmd.CommandText = query; + cmd.CommandType = CommandType.Text; + return cmd.ExecuteNonQuery(); + } + } + } + + /// + public long Ping() + { + try + { + Stopwatch sw = Stopwatch.StartNew(); + using (SqliteConnection sqlite = new("Data Source=" + FilePath)) + { + sqlite.Open(); + using (SqliteCommand cmd = sqlite.CreateCommand()) + { + cmd.CommandText = "SELECT 1;"; + cmd.CommandType = CommandType.Text; + cmd.ExecuteNonQuery(); + } + + sqlite.Close(); + } + + return sw.ElapsedMilliseconds; + } + catch (Exception ex) + { + Log.Error(ex.GetBaseException().ToString()); + return -1; + } + } + + #endregion + + #region Files + + /// + public bool AddFile(SystemFile file) + { + return RunQuery( + $"INSERT OR REPLACE INTO files (profile, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted) VALUES(\"{ProfileId}\", \"{file.Id}\", \"{file.Name}\", \"{file.LocalPath}\", \"{file.RemotePath}\", {file.LastWrite.TotalMilliseconds}, {file.LastUpdate.TotalMilliseconds}, {file.LocalSize}, {file.RemoteSize}, \"{file.Type.ToString()}\", {Converter.ToInt32(file.Hidden)}, {Converter.ToInt32(file.ReadOnly)}, {Converter.ToInt32(file.Deleted)});") > + 0; + } + + /// + public void RemoveFile(SystemFile file) + { + RunQuery($"DELETE FROM files WHERE profile = \"{ProfileId}\" AND localpath = \"{file.LocalPath}\" AND remotepath = \"{file.RemotePath}\""); + } + + /// + public DataTable GetFiles() + { + return GetQuery($"SELECT * FROM files WHERE profile = \"{ProfileId}\" ORDER BY lastupdate DESC"); + } + + /// + public DataTable GetFiles(bool deleted) + { + return GetQuery($"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND deleted = {Converter.ToInt32(deleted)} ORDER BY lastupdate DESC"); + } + + /// + public DataTable GetFiles(string query, bool deleted) + { + return GetQuery($"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND localpath LIKE \"%{query}%\" AND deleted = {Converter.ToInt32(deleted)} ORDER BY lastupdate ASC"); + } + + /// + public DataTable GetFiles(long milliseconds, bool deleted, int limit) + { + return GetQuery($"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND lastupdate <= {milliseconds} AND deleted = {Converter.ToInt32(deleted)} ORDER BY lastupdate ASC LIMIT {limit}"); + } + + /// + public DataTable GetFiles(string query, long milliseconds, bool deleted, int limit) + { + return GetQuery($"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND localpath LIKE \"%{query}%\" AND lastupdate <= {milliseconds} AND deleted = {Converter.ToInt32(deleted)} ORDER BY lastupdate ASC LIMIT {limit}"); + } + + /// + public SystemFile GetFile(string query) + { + DataTable dt = GetQuery($"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND localpath LIKE \"%{query}%\" OR remotepath LIKE \"%{query}%\""); + if (dt.Rows.Count == 0) return null; + return new SystemFile(dt.Rows[0]); + } + + /// + public int GetTotalFiles(bool deleted) + { + DataTable dt = GetQuery($"SELECT COUNT(*) as total FROM files WHERE profile = \"{ProfileId}\" AND deleted = {Converter.ToInt32(deleted)}"); + if (dt.Rows.Count == 0) return 0; + return Convert.ToInt32(dt.Rows[0].Field("total")); + } + + /// + public long GetLocalSize() + { + DataTable dt = GetQuery($"SELECT SUM(LocalSize) as LocalSize FROM files WHERE profile = \"{ProfileId}\""); + if (dt.Rows.Count == 0) return 0; + return Convert.ToInt64(dt.Rows[0].Field("LocalSize")); + } + + /// + public long GetRemoteSize() + { + DataTable dt = GetQuery($"SELECT SUM(RemoteSize) as RemoteSize FROM files WHERE profile = \"{ProfileId}\""); + if (dt.Rows.Count == 0) return 0; + return Convert.ToInt64(dt.Rows[0].Field("RemoteSize")); + } + + #endregion + + #region History + + /// + public int AddHistory(string path, HistoryType type) + { + return RunQuery($"INSERT OR REPLACE INTO history (profile, timestamp, name, path, type) VALUES(\"{ProfileId}\", {UnixTime.Now.TotalMilliseconds}, \"{Path.GetFileName(path)}\", \"{path}\", \"{type.ToString()}\");"); + } + + /// + public DataTable GetHistory(string query, int limit) + { + return GetQuery($"SELECT * FROM history WHERE profile = \"{ProfileId}\" AND path LIKE \"%{query}%\" ORDER BY timestamp DESC LIMIT {limit}"); + } + + /// + public DataTable GetHistory(string query, HistoryType type, int limit) + { + return GetQuery($"SELECT * FROM history WHERE profile = \"{ProfileId}\" AND path LIKE \"%{query}%\" AND type = \"{type.ToString()}\" ORDER BY timestamp DESC LIMIT {limit}"); + } + + #endregion + } +} \ No newline at end of file diff --git a/Parallel.Core/Diagnostics/IProgressReporter.cs b/Parallel.Core/Diagnostics/IProgressReporter.cs new file mode 100644 index 0000000..a774bd1 --- /dev/null +++ b/Parallel.Core/Diagnostics/IProgressReporter.cs @@ -0,0 +1,32 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Models; + +namespace Parallel.Core.Diagnostics +{ + public enum ProgressOperation + { + Archiving, + Downloading, + Uploading, + Compressing, + Decompressing, + Syncing + } + + /// + /// Defines a provider for progress updates. + /// + public interface IProgressReporter + { + /// + /// Reports a progress update. + /// + void Report(ProgressOperation operation, SystemFile file, int current, int total); + + /// + /// Reports a failed update. + /// + void Failed(Exception exception, SystemFile file); + } +} \ No newline at end of file diff --git a/Parallel.Core/Diagnostics/ProgressDebug.cs b/Parallel.Core/Diagnostics/ProgressDebug.cs new file mode 100644 index 0000000..9ca0b6e --- /dev/null +++ b/Parallel.Core/Diagnostics/ProgressDebug.cs @@ -0,0 +1,36 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Models; + +namespace Parallel.Core.Diagnostics +{ + /// + /// Represents a basic progress report debugger. + /// + public class ProgressDebug : IProgressReporter + { + private ProgressOperation currentOperation; + private int progressPercentage; + + /// + public void Report(ProgressOperation operation, SystemFile file, int current, int total) + { + int num = (int)(current / (double)total * 100.0 + 0.5); + if (currentOperation != operation) + { + progressPercentage = -1; + currentOperation = operation; + } + + if (progressPercentage == num || num % 10 != 0) return; + Log.Information($"{operation}: {current} out of {total} ({progressPercentage}%)"); + progressPercentage = num; + } + + /// + public void Failed(Exception exception, SystemFile file) + { + 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/LocalFileEventArgs.cs b/Parallel.Core/Events/LocalFileEventArgs.cs new file mode 100644 index 0000000..399768c --- /dev/null +++ b/Parallel.Core/Events/LocalFileEventArgs.cs @@ -0,0 +1,19 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.IO; +using System; +using System.IO; +using Parallel.Core.Models; + +namespace Parallel.Core.Events +{ + public class LocalFileEventArgs : EventArgs + { + public SystemFile SystemFile { get; } + + public LocalFileEventArgs(string file) + { + SystemFile = new SystemFile(new FileInfo(file)); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Events/MessageRecievedEventArgs.cs b/Parallel.Core/Events/MessageRecievedEventArgs.cs new file mode 100644 index 0000000..a8ae445 --- /dev/null +++ b/Parallel.Core/Events/MessageRecievedEventArgs.cs @@ -0,0 +1,14 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Net.Sockets; +using System.Text; +using Parallel.Core.Utils; + +namespace Parallel.Core.Events +{ + public class MessageRecievedEventArgs(UdpReceiveResult result) + { + public DateTime TimeStamp { get; } = DateTime.Now; + public string Message { get; } = Encryption.Decode(Encoding.UTF8.GetString(result.Buffer)); + } +} \ No newline at end of file diff --git a/Parallel.Core/Events/TransferFailedEventArgs.cs b/Parallel.Core/Events/TransferFailedEventArgs.cs new file mode 100644 index 0000000..9ce7457 --- /dev/null +++ b/Parallel.Core/Events/TransferFailedEventArgs.cs @@ -0,0 +1,20 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Security.Cryptography.X509Certificates; +using Parallel.Core.IO; +using Parallel.Core.Models; + +namespace Parallel.Core.Events +{ + public class TransferFailedEventArgs : EventArgs + { + public SystemFile File { get; } + public Exception Exception { get; } + + public TransferFailedEventArgs(SystemFile file, Exception exception) + { + File = file; + Exception = exception; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Events/TransferUpdateEventArgs.cs b/Parallel.Core/Events/TransferUpdateEventArgs.cs new file mode 100644 index 0000000..fab2c94 --- /dev/null +++ b/Parallel.Core/Events/TransferUpdateEventArgs.cs @@ -0,0 +1,33 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Security.Cryptography.X509Certificates; +using Parallel.Core.IO; +using Parallel.Core.Models; + +namespace Parallel.Core.Events +{ + public class TransferUpdateEventArgs : EventArgs + { + /// + /// The recently transferred file. + /// + public SystemFile File { get; } + + /// + /// The amount of files already transferred. + /// + public int Finished { get; } + + /// + /// The total amount of files to transfer. + /// + public int Total { get; } + + public TransferUpdateEventArgs(SystemFile file, int finished, int total) + { + File = file; + Finished = finished; + Total = total; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Extensions/Json/UnixTimeConverter.cs b/Parallel.Core/Extensions/Json/UnixTimeConverter.cs new file mode 100644 index 0000000..a430ec0 --- /dev/null +++ b/Parallel.Core/Extensions/Json/UnixTimeConverter.cs @@ -0,0 +1,57 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json; +using Parallel.Core.Utils; + +namespace Parallel.Core.Extensions.Json +{ + public class UnixTimeConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType.IsValueType; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + UnixTime unix; + if (reader.TokenType == JsonToken.Integer) + { + unix = UnixTime.FromMilliseconds(Convert.ToInt64(reader.Value)); + } + else if (reader.TokenType == JsonToken.String) + { + unix = UnixTime.Parse((string)reader.Value); + } + else + { + throw new JsonSerializationException($"Unexpected token parsing date. Expected Integer or String, got {reader.TokenType}"); + } + + return unix; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + long seconds; + //if(value is DateTime dateTime) + //{ + // seconds = (long)dateTime.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; + //} + //else if(value is DateTimeOffset dateTimeOffset) + //{ + // seconds = (long)dateTimeOffset.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; + //} + if (value is UnixTime unixTime) + { + seconds = unixTime.TotalMilliseconds; + } + else + { + throw new JsonSerializationException("Expected date object value."); + } + + writer.WriteValue(seconds); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Backup/BackupManager.cs b/Parallel.Core/IO/Backup/BackupManager.cs new file mode 100644 index 0000000..875c06f --- /dev/null +++ b/Parallel.Core/IO/Backup/BackupManager.cs @@ -0,0 +1,28 @@ +// 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 new file mode 100644 index 0000000..53f9b7e --- /dev/null +++ b/Parallel.Core/IO/Backup/BaseFileManager.cs @@ -0,0 +1,66 @@ +// 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 new file mode 100644 index 0000000..20c2084 --- /dev/null +++ b/Parallel.Core/IO/Backup/DeltaBackupManager.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.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 new file mode 100644 index 0000000..1c30a8b --- /dev/null +++ b/Parallel.Core/IO/Backup/FileBackupManager.cs @@ -0,0 +1,73 @@ +// 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); + Database.AddHistory(file.LocalPath, HistoryType.Archived); + Database.AddFile(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; + Database.AddHistory(file.LocalPath, HistoryType.Synced); + Database.AddFile(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/Backup/IBackupManager.cs b/Parallel.Core/IO/Backup/IBackupManager.cs new file mode 100644 index 0000000..d819857 --- /dev/null +++ b/Parallel.Core/IO/Backup/IBackupManager.cs @@ -0,0 +1,63 @@ +// 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 +{ + /// + /// Defines the methods needed for backing up a file system. + /// + public interface IBackupManager + { + /// + /// The back-up connection profile. + /// + public ProfileConfig Profile { get; } + + /// + /// The associated database connection. + /// + IDatabase Database { get; set; } + + /// + /// The associated file system connection. + /// + IFileSystem FileSystem { get; set; } + + /// + /// The current machine name. + /// + string MachineName { get; } + + /// + /// The root directory of the back-up. + /// + string RootFolder { get; set; } + + + /// + /// Initializes the backup manager by logging into the and + /// + /// + bool Initialize(); + + /// + /// Backs up a path. Can be either a file or directory. + /// + /// + /// + Task BackupFilesAsync(SystemFile[] files, IProgressReporter progress); + + /// + /// Restores a path. Can be either a file or directory. + /// + /// + /// + Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress); + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs new file mode 100644 index 0000000..9268564 --- /dev/null +++ b/Parallel.Core/IO/FileSystem/DotNetFileSystem.cs @@ -0,0 +1,164 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Diagnostics; +using System.IO.Compression; +using Microsoft.VisualBasic.FileIO; +using Newtonsoft.Json.Linq; +using Parallel.Core.Diagnostics; +using Parallel.Core.Models; +using Parallel.Core.Settings; +using Parallel.Core.Utils; +using SearchOption = System.IO.SearchOption; + +namespace Parallel.Core.IO.FileSystem +{ + /// + /// Represents the wrapper for a default dotnet file system. + /// + public class DotNetFileSystem : IFileSystem + { + private readonly FileSystemCredentials _credentials; + + /// + /// Represents an for interacting with physical machine hardware. + /// + /// The credentials to log in with. + public DotNetFileSystem(FileSystemCredentials credentials) + { + _credentials = credentials; + } + + /// + public Task CreateDirectoryAsync(string path) + { + Directory.CreateDirectory(path); + return Task.CompletedTask; + } + + /// + public Task DeleteDirectoryAsync(string path) + { + //Directory.Delete(path); + return Task.CompletedTask; + } + + /// + public Task DeleteFileAsync(string path) + { + if (File.Exists(path)) + { + File.SetAttributes(path, ~FileAttributes.ReadOnly & File.GetAttributes(path)); + Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + } + + return Task.CompletedTask; + } + + /// + public async Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) + { + if (!files.Any()) return; + for (int i = 0; i < files.Length; i++) + { + 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 GZipStream gzipStream = new GZipStream(openStream, CompressionMode.Decompress); + await gzipStream.CopyToAsync(createStream); + + Log.Debug($"Downloaded '{file.LocalPath}' in {sw.ElapsedMilliseconds}ms"); + } + } + + /// + public Task GetDirectoryNameAsync(string path) + { + return Task.FromResult(Path.GetDirectoryName(path)); + } + + /// + public Task> GetFilesAsync() + { + 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); + } + + /// + public Task GetFilesAsync(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()); + } + + /// + public Task GetFileAsync(string path) + { + FileInfo fi = new(path); + return Task.FromResult(new SystemFile(path) + { + Name = fi.Name, + RemotePath = fi.FullName, + RemoteSize = fi.Length + }); + } + + /// + public Task PingAsync() + { + Stopwatch sw = Stopwatch.StartNew(); + if (!Directory.Exists(PathBuilder.RootDirectory(_credentials))) return Task.FromResult(-1); + return Task.FromResult(sw.ElapsedMilliseconds); + } + + /// + public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) + { + if (!files.Any()) return; + for (int i = 0; i < files.Length; i++) + { + 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); + + await using FileStream createStream = File.Create(file.RemotePath); + await using FileStream openStream = File.OpenRead(file.LocalPath); + 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); + } + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/FileSystemManager.cs b/Parallel.Core/IO/FileSystem/FileSystemManager.cs new file mode 100644 index 0000000..97fa7b5 --- /dev/null +++ b/Parallel.Core/IO/FileSystem/FileSystemManager.cs @@ -0,0 +1,48 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Settings; + +namespace Parallel.Core.IO.FileSystem +{ + /// + /// The supported file service types. + /// + public enum FileService + { + /// + /// A local file system either through external or networked drives. + /// + Local, + + /// + /// A remote file storage server through secure shell. + /// + Remote, + + /// + /// A cloud storage server hosted through Amazon simple storage service. + /// + Cloud, + }; + + /// + /// Represents the way to connect to different file system associations. This class cannot be inherited. + /// + public static class FileSystemManager + { + /// + /// Creates a new file system association. + /// + /// The credentials needed for the associated file system. + public static IFileSystem CreateNew(FileSystemCredentials credentials) + { + return credentials?.Service switch + { + FileService.Local => new DotNetFileSystem(credentials), + FileService.Remote => new SftpFileSystem(credentials), + //FileService.Cloud => new AmazonS3FileSystem(credentials), + _ => null + }; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/IFileSystem.cs b/Parallel.Core/IO/FileSystem/IFileSystem.cs new file mode 100644 index 0000000..7f043da --- /dev/null +++ b/Parallel.Core/IO/FileSystem/IFileSystem.cs @@ -0,0 +1,83 @@ +// Copyright 2025 Kyle Ebbinga + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Parallel.Core.Diagnostics; +using Parallel.Core.Models; + +namespace Parallel.Core.IO.FileSystem +{ + /// + /// Defines the way for communicating with a file system. + /// + public interface IFileSystem + { + /// + /// Creates all directories and subdirectories in the specified path unless they already exist. + /// + /// + Task CreateDirectoryAsync(string path); + + /// + /// Deletes the specified directory. + /// + /// + Task DeleteDirectoryAsync(string path); + + /// + /// Deletes the specified file. + /// + /// + Task DeleteFileAsync(string path); + + /// + /// Downloads a file 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. + /// + /// A dictionary of s with the key being the backup path adn the value being the associated . + Task> GetFilesAsync(); + + /// + /// Gets all the files in the current directory. + /// + /// + /// A read-only collection of s. + Task GetFilesAsync(string path); + + /// + /// Gets a file on the associated file system. + /// + /// + /// + Task GetFileAsync(string path); + + /// + /// Pings the remote file system. + /// + /// The time, in milliseconds, of the database latency. -1 if disconnected. + Task PingAsync(); + + /// + /// Uploads a file to the associated file system. + /// + /// + /// + Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress); + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/FileSystem/SftpFileSystem.cs b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs new file mode 100644 index 0000000..58ec6cd --- /dev/null +++ b/Parallel.Core/IO/FileSystem/SftpFileSystem.cs @@ -0,0 +1,202 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Diagnostics; +using Parallel.Core.Settings; +using Renci.SshNet; +using Renci.SshNet.Sftp; +using System.IO.Compression; +using Newtonsoft.Json.Linq; +using Parallel.Core.Diagnostics; +using Parallel.Core.Models; +using Parallel.Core.Utils; + +namespace Parallel.Core.IO.FileSystem +{ + /// + /// Represents the wrapper for an SFTP file system through SSH. + /// + public class SftpFileSystem : IFileSystem + { + private readonly ConnectionInfo _connectionInfo; + + /// + /// Represents an for interacting with an SSH server. + /// + /// The credentials to log in with. + public SftpFileSystem(FileSystemCredentials credentials) + { + Console.WriteLine(JObject.FromObject(credentials)); + _connectionInfo = new ConnectionInfo(credentials.Address, credentials.Username, new PasswordAuthenticationMethod(credentials.Username, Encryption.Decode(credentials.Password))); + } + + /// + public async Task CreateDirectoryAsync(string path) + { + using (SftpClient sftp = new SftpClient(_connectionInfo)) + { + sftp.Connect(); + if (sftp.IsConnected) + { + string parentDir = string.Empty; + foreach (string subPath in path.Split('/')) + { + parentDir += $"/{subPath}"; + if (!await sftp.ExistsAsync(parentDir)) + { + await sftp.CreateDirectoryAsync(parentDir); + } + } + } + + sftp.Disconnect(); + } + } + + /// + public async Task DeleteDirectoryAsync(string path) + { + using (SftpClient sftp = new SftpClient(_connectionInfo)) + { + sftp.Connect(); + if (sftp.IsConnected && await sftp.ExistsAsync(path)) + { + await sftp.DeleteDirectoryAsync(path); + } + + sftp.Disconnect(); + } + } + + /// + public async Task DeleteFileAsync(string path) + { + using (SftpClient sftp = new SftpClient(_connectionInfo)) + { + sftp.Connect(); + if (sftp.IsConnected && await sftp.ExistsAsync(path)) + { + await sftp.DeleteAsync(path); + } + + sftp.Disconnect(); + } + } + + public Task DownloadFilesAsync(SystemFile[] files, IProgressReporter progress) + { + throw new NotImplementedException(); + } + + public Task GetDirectoryNameAsync(string path) + { + throw new NotImplementedException(); + } + + public Task> GetFilesAsync() + { + throw new NotImplementedException(); + } + + /// + public async Task GetFilesAsync(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(); + } + + /// + 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() + { + 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(); + } + + return sw.ElapsedMilliseconds; + } + + /// + public async Task UploadFilesAsync(SystemFile[] files, IProgressReporter progress) + { + using SftpClient sftp = new SftpClient(_connectionInfo); + sftp.Connect(); + if (sftp.IsConnected) + { + for (int i = 0; i < files.Length; i++) + { + 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); + + string parentDir = string.Empty; + foreach (string subPath in file.RemotePath.Split('/')) + { + parentDir += $"/{subPath}"; + if (!await sftp.ExistsAsync(parentDir)) + { + await sftp.CreateDirectoryAsync(parentDir); + } + } + + await using SftpFileStream createStream = sftp.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); + + Log.Debug($"Uploaded '{file.RemotePath}' in {sw.ElapsedMilliseconds}ms"); + } + } + + sftp.Disconnect(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/PathBuilder.cs b/Parallel.Core/IO/PathBuilder.cs new file mode 100644 index 0000000..e2b4bf9 --- /dev/null +++ b/Parallel.Core/IO/PathBuilder.cs @@ -0,0 +1,103 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Runtime.InteropServices; +using Parallel.Core.IO.FileSystem; +using Parallel.Core.Models; +using Parallel.Core.Settings; +using Parallel.Core.Utils; + +namespace Parallel.Core.IO +{ + /// + /// Represents the way to build paths on different operating systems. + /// + public class PathBuilder + { + public static string TempDirectory + { + get + { + string tempFolder = Path.Combine(Path.GetTempPath(), $"parallel_{UnixTime.Now.TotalMilliseconds}"); + if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); + return tempFolder; + } + } + + /// + /// Gets the corresponding directory for program data based on the . + /// + public static string ProgramData + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Parallel"); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "/etc/Parallel"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Parallel"); + } + + throw new PlatformNotSupportedException("Unsupported OS detected."); + } + } + + /// + /// Builds the path for the local file system. + /// + /// + /// + /// + public static string Local(string path, FileSystemCredentials credentials) + { + string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName); + string main = path.Replace("/", "\\").Replace(root, string.Empty).Replace(".gz", string.Empty); + + Console.WriteLine(root); + Console.WriteLine(main); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return main.Substring(1, main.Length - 1).Insert(1, ":"); + } + + return main.Replace(@"\", "/"); + } + + public static string RootDirectory(FileSystemCredentials credentials) + { + 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 + }; + } + + /// + /// Builds the path on the remote . + /// + /// + /// + /// + public static string Remote(string path, FileSystemCredentials credentials) + { + string root = Path.Combine(credentials.RootDirectory, "Parallel", Environment.MachineName, path.Replace(":", string.Empty)) + ".gz"; + return credentials.Service switch + { + FileService.Local => root, + FileService.Remote => root.Replace('\\', '/'), + _ => null + }; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Recovery/RecoveryManager.cs b/Parallel.Core/IO/Recovery/RecoveryManager.cs new file mode 100644 index 0000000..df396f9 --- /dev/null +++ b/Parallel.Core/IO/Recovery/RecoveryManager.cs @@ -0,0 +1,85 @@ +// 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; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Recovery/RecoveryPoint.cs b/Parallel.Core/IO/Recovery/RecoveryPoint.cs new file mode 100644 index 0000000..ea57c4d --- /dev/null +++ b/Parallel.Core/IO/Recovery/RecoveryPoint.cs @@ -0,0 +1,55 @@ +// 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/Rsync/RsyncAlgorithm.cs b/Parallel.Core/IO/Rsync/RsyncAlgorithm.cs new file mode 100644 index 0000000..42fbbd9 --- /dev/null +++ b/Parallel.Core/IO/Rsync/RsyncAlgorithm.cs @@ -0,0 +1,61 @@ +// Copyright 2025 Kyle Ebbinga + +using FastRsync.Core; +using FastRsync.Delta; +using FastRsync.Diagnostics; +using FastRsync.Signature; + +namespace Parallel.Core.IO.Rsync +{ + /// + /// Represents the functions for using the rsync algorithm. + /// + public abstract class RsyncAlgorithm + { + private static readonly IProgress Logging = new RsyncProgress(); + private static int DeltaSize = 206; + + /// + /// Creates a new signature file. A signature file contains checksums for file changes. + /// + /// The path to the original version of the file. + /// The path to the signature of the original file. + public static async Task CreateSignatureAsync(string originalFilePath, string signatureFilePath) + { + SignatureBuilder signatureBuilder = new SignatureBuilder(); + await using FileStream originalStream = new FileStream(originalFilePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read); + await using FileStream signatureStream = new FileStream(signatureFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); + await signatureBuilder.BuildAsync(originalStream, new SignatureWriter(signatureStream)); + } + + /// + /// Creates a new delta file. A delta file contains the data that changed in the file. + /// + /// The path to the new version of the file. + /// The path to the signature of the original file. + /// The path to the delta of the changed file data. + public static async Task CreateDeltaAsync(string newFilePath, string signatureFilePath, string deltaFilePath) + { + DeltaBuilder deltaBuilder = new DeltaBuilder(); + await using FileStream newFileStream = new FileStream(newFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using FileStream signatureStream = new FileStream(signatureFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using FileStream deltaStream = new FileStream(deltaFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); + await deltaBuilder.BuildDeltaAsync(newFileStream, new SignatureReader(signatureStream, Logging), new AggregateCopyOperationsDecorator(new BinaryDeltaWriter(deltaStream))); + } + + /// + /// Applies a delta file to + /// + /// The path to the new version of the file. + /// The path to the original version of the file. + /// The path to the delta of the changed file data. + public static async Task ApplyDeltaAsync(string newFilePath, string originalFilePath, string deltaFilePath) + { + DeltaApplier deltaApplier = new DeltaApplier(); + await using FileStream originalStream = new FileStream(newFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using FileStream deltaStream = new FileStream(deltaFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using FileStream newFileStream = new FileStream(originalFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + await deltaApplier.ApplyAsync(originalStream, new BinaryDeltaReader(deltaStream, Logging), newFileStream); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Rsync/RsyncProgress.cs b/Parallel.Core/IO/Rsync/RsyncProgress.cs new file mode 100644 index 0000000..29640cb --- /dev/null +++ b/Parallel.Core/IO/Rsync/RsyncProgress.cs @@ -0,0 +1,31 @@ +// Copyright 2025 Kyle Ebbinga + +using FastRsync.Diagnostics; + +namespace Parallel.Core.IO.Rsync +{ + /// + public class RsyncProgress : IProgress + { + private ProgressOperationType currentOperation; + private int progressPercentage; + + + /// + public void Report(ProgressReport progress) + { + int num = (int)(progress.CurrentPosition / (double)progress.Total * 100.0 + 0.5); + if (currentOperation != progress.Operation) + { + progressPercentage = -1; + currentOperation = progress.Operation; + } + + if (progressPercentage != num && num % 10 == 0) + { + progressPercentage = num; + Log.Information($"{progress.Operation}: {progress.CurrentPosition} out of {progress.Total} ({progressPercentage}%)"); + } + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/ScanFileSystemResult.cs b/Parallel.Core/IO/ScanFileSystemResult.cs new file mode 100644 index 0000000..18623df --- /dev/null +++ b/Parallel.Core/IO/ScanFileSystemResult.cs @@ -0,0 +1,25 @@ +// Copyright 2025 Kyle Ebbinga + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Parallel.Core.Models; + +namespace Parallel.Core.IO +{ + public struct ScanFileSystemResult + { + public SystemFile[] BackupFiles { get; } + public SystemFile[] DeletedFiles { get; } + public SystemFile[] IgnoredFiles { get; } + + public ScanFileSystemResult(IEnumerable localFiles, IEnumerable deletedFiles, IEnumerable ignoredFiles) + { + BackupFiles = localFiles.ToArray(); + DeletedFiles = deletedFiles.ToArray(); + IgnoredFiles = ignoredFiles.ToArray(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/IO/Scanning/FileScanner.cs b/Parallel.Core/IO/Scanning/FileScanner.cs new file mode 100644 index 0000000..5ab6cb0 --- /dev/null +++ b/Parallel.Core/IO/Scanning/FileScanner.cs @@ -0,0 +1,374 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Data; +using System.Diagnostics; +using Parallel.Core.Database; +using Parallel.Core.IO.Backup; +using Parallel.Core.Models; +using Parallel.Core.Settings; +using Parallel.Core.Utils; +using FileInfo = System.IO.FileInfo; + +namespace Parallel.Core.IO.Scanning +{ + /// + /// Represents file system scanning. + /// + public class FileScanner + { + private readonly ProfileConfig _profile; + private readonly IDatabase _db; + + public FileScanner(ProfileConfig profile, IDatabase database) + { + _profile = profile; + _db = database; + } + + public FileScanner(IBackupManager backup) + { + _profile = backup.Profile; + _db = backup.Database; + } + + /// + /// Scans all marked backup locations for file changes. + /// + /// + public SystemFile[] GetFileChanges() + { + List scannedFiles = new(); + foreach (string path in _profile.BackupDirectories.ToArray()) + { + SystemFile[] files = GetFileChanges(path, _profile.IgnoreDirectories.ToArray()); + scannedFiles.AddRange(files); + } + + Log.Information($"Backing up {scannedFiles.Where(x => !x.Deleted).Count()} files..."); + return scannedFiles.ToArray(); + } + + /// + /// Asynchronously scans for file changes in a directory. + /// + /// + /// + /// + public Task GetFileChangesAsync(string path, string[] ignoreFolders) + { + return Task.Run(() => GetFileChanges(path, ignoreFolders)); + } + + /// + /// Scans for file changes in a directory. + /// + /// + /// + /// A list of files that have changed since the last backup. + public SystemFile[] GetFileChanges(string path, string[] ignoreFolders) + { + if (!Directory.Exists(path)) return Array.Empty(); + + List scannedFiles = new(); + List systemFiles = FileScanner.GetFiles(path, ".", ignoreFolders).ToList(); + DataTable dataTable = _db.GetFiles(path, false); + Stopwatch sw = Stopwatch.StartNew(); + + foreach (DataRow row in dataTable.Rows) + { + SystemFile dlf = new(row); + + // Checks if the local file has a valid path and is part of a backup folder. + if (dlf.LocalPath != null && dlf.LocalPath.Contains(path)) + { + // Checks if a LocalFile exists on the current file system. + if (File.Exists(dlf.LocalPath) && dlf.RemotePath != null) + { + SystemFile lfi = new(new FileInfo(dlf.LocalPath)); + if (IsIgnored(lfi.LocalPath, ignoreFolders)) + { + Log.Debug($"Is ignored -> {lfi.LocalPath}"); + + lfi.Deleted = true; + scannedFiles.Add(lfi); + } + + if (dlf.LastWrite.TotalMilliseconds < lfi.LastWrite.TotalMilliseconds) + { + Log.Debug($"Changed -> {lfi.LocalPath}"); + + // Changed file + dlf.Deleted = false; + scannedFiles.Add(lfi); + } + + systemFiles.Remove(lfi.LocalPath); + } + else + { + // Adds deleted files + Log.Debug($"Deleted -> {dlf.LocalPath}"); + + dlf.Deleted = true; + scannedFiles.Add(dlf); + } + } + else + { + // Deletes ignored files + Log.Debug($"No contains Ignored -> {dlf.LocalPath}"); + + dlf.Deleted = true; + scannedFiles.Add(dlf); + } + } + + Log.Debug($"{systemFiles.Count} files are untracked! Adding..."); + if (systemFiles.Count > 0) + { + foreach (string file in systemFiles.ToArray()) + { + if (File.Exists(file) && !IsIgnored(file, ignoreFolders)) + { + Log.Debug($"Created -> {file}"); + scannedFiles.Add(new SystemFile(new FileInfo(file))); + systemFiles.Remove(file); + } + } + } + + Log.Debug($"{systemFiles.Count} files remaining."); + Log.Information($"Found {dataTable.Rows.Count.ToString("N0")} files in '{path}'. ({sw.ElapsedMilliseconds}ms)"); + return scannedFiles.ToArray(); + } + + /// + /// Gets the total size, in bytes, of a directory. + /// + /// The path of the directory. + public static long GetDirectorySize(string path) + { + long size = 0; + DirectoryInfo di = new(path); + EnumerationOptions options = new() + { + IgnoreInaccessible = true, + RecurseSubdirectories = true + }; + + foreach (FileInfo fi in di.EnumerateFiles("*", options)) + { + size += fi.Length; + } + + return size; + } + + /// + /// Gets an array of empty directories. + /// + /// The root directory to search. + /// If it should search recursively. + /// An array of empty directories. + public static DirectoryInfo[] GetEmptyDirectories(string path, bool recursive = true) + { + List list = new(); + DirectoryInfo directory = new(path); + EnumerationOptions options = new() + { + IgnoreInaccessible = true, + RecurseSubdirectories = recursive + }; + + foreach (DirectoryInfo di in directory.EnumerateDirectories("*", options)) + { + Log.Debug($"Checking -> {di.FullName}"); + if (!di.EnumerateFileSystemInfos().Any()) list.Add(di); + } + + Log.Debug($"Found {list.Count} empty directories"); + return list.ToArray(); + } + + /// + /// Gets an array of directories older than a specified time. + /// This includes all directories outside of Parallel's back up directories. + /// + /// The root directory to search. + /// 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) + { + Dictionary list = new(); + DirectoryInfo directory = new(path); + EnumerationOptions options = new() + { + IgnoreInaccessible = true, + RecurseSubdirectories = recursive + }; + + foreach (DirectoryInfo di in directory.EnumerateDirectories("*", options)) + { + DateTime compare = di.CreationTime > di.LastWriteTime ? di.CreationTime : di.LastWriteTime; + bool older = start.TotalMilliseconds >= new UnixTime(compare).TotalMilliseconds; + bool exists = list.Keys.Any(d => di.FullName.StartsWith(d.FullName)); + if (!exists && older) list.Add(di, compare); + } + + Log.Debug($"Found {list.Count} cleanable directories"); + return list.OrderBy(d => d.Value).ToDictionary().Keys.ToArray(); + } + + public static FileInfo[] 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, "*")) + { + FileInfo fi = new FileInfo(file); + DateTime compare = fi.CreationTime > fi.LastWriteTime ? fi.CreationTime : fi.LastWriteTime; + bool older = start.TotalMilliseconds >= new UnixTime(compare).TotalMilliseconds; + if (older) list.Add(fi, compare); + } + + Log.Debug($"Found {list.Count} cleanable files"); + return list.OrderBy(d => d.Value).ToDictionary().Keys.ToArray(); + } + + public static IEnumerable GetFiles(string root, string searchPattern) + { + return GetFiles(root, searchPattern, Array.Empty()); + } + + public static IEnumerable GetFiles(string root, string searchPattern, string[] exempt) + { + Stack pending = new(); + pending.Push(root); + 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); + } + + if (next != null && next.Count() != 0) + { + foreach (string file in next) yield return file; + } + + try + { + next = Directory.EnumerateDirectories(path); + foreach (string subdir in next) pending.Push(subdir); + } + catch + { + Log.Debug("No folder access -> " + path); + } + } + } + + /// + /// Scans a directory for duplicate files with the same name and size. + /// + /// + /// A array of duplicate files, in order of most duplicate entries, where the key refers to the filename, and the values are an array of s in order of oldest to newest. + public static Dictionary GetDuplicateFiles(string path) + { + Dictionary> dict = new(); + IEnumerable files = GetFiles(path, "*"); + foreach (string file in files) + { + SystemFile entry = new(new FileInfo(file)); + if (dict.TryGetValue(entry.Name, out List value)) + { + SystemFile key = value.FirstOrDefault(); + if (entry.LocalSize.Equals(key.LocalSize)) + { + value.Add(entry); + } + } + else + { + 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()); + } + + /// + /// Checks if the given path is a directory. + /// + /// + /// True if path is a directory, otherwise false. + public static bool IsDirectory(string path) + { + return Directory.Exists(path) && !File.Exists(path); + } + + /// + /// Checks if the given path is a file. + /// + /// + /// True if path is a file, otherwise false. + public static bool IsFile(string path) + { + return !Directory.Exists(path) && File.Exists(path); + } + + /// + /// Checks if the given path is set to be ignored. + /// + /// + /// + /// True if ignored, otherwise false. + public static bool IsIgnored(string path, string[] exempt) + { + foreach (string entry in exempt) + { + if (path.StartsWith(entry)) + { + return true; + } + + if (entry.EndsWith('/')) + { + string[] folders = path.Split('\\'); + foreach (string dir in folders) + { + if (dir.ToLower() == entry.Remove(entry.Length - 1, 1).ToLower()) + { + return true; + } + } + } + + if (entry.StartsWith('*')) + { + if (path.EndsWith(entry.Replace("*", string.Empty))) + { + return true; + } + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Models/SystemFile.cs b/Parallel.Core/Models/SystemFile.cs new file mode 100644 index 0000000..02a7ace --- /dev/null +++ b/Parallel.Core/Models/SystemFile.cs @@ -0,0 +1,167 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Data; +using Parallel.Core.Data; +using Parallel.Core.Utils; + +namespace Parallel.Core.Models +{ + /// + /// Represents a file managed by Parallel. + /// + public class SystemFile + { + /// + /// The unique identifier of the file. + /// + public string Id { get; } = string.Empty; + + /// + /// The name of the file. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The path of the file on the local machine. + /// + public string LocalPath { get; set; } = string.Empty; + + /// + /// The path of the file in the backup file system. + /// + public string RemotePath { get; set; } = string.Empty; + + /// + /// The time the current file was last written to. + /// + public UnixTime LastWrite { get; set; } = UnixTime.Now; + + /// + /// The time the file was either last saved or deleted. + /// + public UnixTime LastUpdate { get; set; } = UnixTime.Now; + + /// + /// The size, in bytes, of the file on the local machine. + /// + public long LocalSize { get; set; } = 0; + + /// + /// The size, in bytes, of the file in the remote backup. + /// + public long RemoteSize { get; set; } = 0; + + /// + /// The category of the file. + /// + public FileCategory Type { get; set; } = FileCategory.Other; + + /// + /// If the file is currently hidden on the local machine. + /// + public bool Hidden { get; set; } = false; + + /// + /// If the file is currently read-only on the local machine. + /// + public bool ReadOnly { get; set; } = false; + + /// + /// If the file is currently deleted on the local machine. + /// + public bool Deleted { get; set; } = false; + + /// + /// If the file is encrypted in the backup. + /// + 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(); + + + /// + /// Initializes a new instance of the class with default properties. + /// + public SystemFile(string path) + { + Id = HashGenerator.CreateSHA1(path); + } + + /// + /// Initializes a new instance of the class from a . + /// + /// + public SystemFile(FileInfo fileInfo) + { + Id = HashGenerator.CreateSHA1(fileInfo.FullName); + Name = fileInfo.Name; + LocalPath = fileInfo.FullName; + LocalSize = fileInfo.Length; + RemoteSize = fileInfo.Length; + Type = FileTypes.GetFileCategory(Path.GetExtension(fileInfo.Name)); + LastWrite = new UnixTime(fileInfo.LastWriteTime); + LastUpdate = UnixTime.Now; + Deleted = !fileInfo.Exists; + + if (fileInfo.Attributes.HasFlag(FileAttributes.Hidden)) + { + Hidden = true; + } + + if (fileInfo.Attributes.HasFlag(FileAttributes.ReadOnly)) + { + ReadOnly = true; + } + } + + /// + /// Initializes a new instance of the class from a . + /// + /// + public SystemFile(DataRow row) + { + Id = row.Field("id"); + Name = row.Field("name"); + LocalPath = row.Field("localpath"); + RemotePath = row.Field("remotepath"); + LocalSize = Convert.ToInt64(row.Field("localsize")); + RemoteSize = Convert.ToInt64(row.Field("remotesize")); + LastWrite = UnixTime.FromMilliseconds(row.Field("lastwrite")); + LastUpdate = UnixTime.FromMilliseconds(row.Field("lastupdate")); + Type = (FileCategory)Enum.Parse(typeof(FileCategory), row.Field("type")); + Hidden = Converter.ToBool(Convert.ToInt32(row.Field("hidden"))); + ReadOnly = Converter.ToBool(Convert.ToInt32(row.Field("readonly"))); + Deleted = Converter.ToBool(Convert.ToInt32(row.Field("deleted"))); + } + + public bool Equals(SystemFile value) + { + bool?[] results = + [ + this?.Id != null && value?.Id != null ? this.Id.Equals(value.Id) : (bool?)null, + this?.Name != null && value?.Name != null ? this.Name.Equals(value.Name) : (bool?)null, + this?.LocalPath != null && value?.LocalPath != null ? this.LocalPath.Equals(value.LocalPath) : (bool?)null, + this?.RemotePath != null && value?.RemotePath != null ? this.RemotePath.Equals(value.RemotePath) : (bool?)null, + value?.LocalSize != null ? this.LocalSize.Equals(value.LocalSize) : (bool?)null, + value?.RemoteSize != null ? this.RemoteSize.Equals(value.RemoteSize) : (bool?)null, + value?.Type != null ? this.Type.Equals(value.Type) : (bool?)null, + 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, + ]; + + return results.All(b => b != null && (bool)b); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Net/Communication.cs b/Parallel.Core/Net/Communication.cs new file mode 100644 index 0000000..eb9f480 --- /dev/null +++ b/Parallel.Core/Net/Communication.cs @@ -0,0 +1,70 @@ +// 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/MessageResult.cs b/Parallel.Core/Net/MessageResult.cs new file mode 100644 index 0000000..0bd428a --- /dev/null +++ b/Parallel.Core/Net/MessageResult.cs @@ -0,0 +1,8 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Core.Net +{ + public struct MessageResult + { + } +} \ No newline at end of file diff --git a/Parallel.Core/Parallel.Core.csproj b/Parallel.Core/Parallel.Core.csproj new file mode 100644 index 0000000..2093d47 --- /dev/null +++ b/Parallel.Core/Parallel.Core.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + enable + disable + 1.0.1.0 + Entex Interactive, LLC + Copyright Entex Interactive, LLC. All Rights Reserved. + $(AssemblyVersion) + $(VersionPrefix)$(AssemblyVersion) + $(Company) + Parallel.Core + True + Parallel.Core + + + + + + + + + + + + + ..\EntexSharp.dll + + + C:\Users\kebbi\.nuget\packages\newtonsoft.json\13.0.3\lib\net6.0\Newtonsoft.Json.dll + + + + + + + + + diff --git a/Parallel.Core/Settings/DatabaseCredentials.cs b/Parallel.Core/Settings/DatabaseCredentials.cs new file mode 100644 index 0000000..a8f7914 --- /dev/null +++ b/Parallel.Core/Settings/DatabaseCredentials.cs @@ -0,0 +1,45 @@ +// 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 new file mode 100644 index 0000000..db02bf1 --- /dev/null +++ b/Parallel.Core/Settings/FileSystemCredentials.cs @@ -0,0 +1,37 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Parallel.Core.IO.FileSystem; + +namespace Parallel.Core.Settings +{ + /// + /// Represents credentials used to gain access to various s. + /// + 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; + + /// + /// If the file system is encrypting files. + /// + public bool Encrypt { get; set; } = false; + + /// + /// The master key used for encryption. + /// + public string? EncryptionKey { get; set; } = null; + + public FileSystemCredentials() { } + + public FileSystemCredentials(string root) + { + RootDirectory = root; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Settings/ParallelSettings.cs b/Parallel.Core/Settings/ParallelSettings.cs new file mode 100644 index 0000000..412a8b5 --- /dev/null +++ b/Parallel.Core/Settings/ParallelSettings.cs @@ -0,0 +1,70 @@ +// 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 new file mode 100644 index 0000000..93c2d17 --- /dev/null +++ b/Parallel.Core/Settings/ProfileConfig.cs @@ -0,0 +1,226 @@ +// 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 name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + string path = Path.Combine(ParallelSettings.ProfilesDir, name + ".json"); + if (File.Exists(path)) + { + string json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); + } + else + { + 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(Path.GetFileNameWithoutExtension(settings.Profiles.FirstOrDefault())); + 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/Utils/Converter.cs b/Parallel.Core/Utils/Converter.cs new file mode 100644 index 0000000..4295054 --- /dev/null +++ b/Parallel.Core/Utils/Converter.cs @@ -0,0 +1,63 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Net; + +namespace Parallel.Core.Utils +{ + /// + /// Converts a data type to another data type. This class cannot be inherited. + /// + public static class Converter + { + /// + /// Converts a int32 into a boolean. + /// + /// The int32 value. + /// If 1 true, false otherwise. + public static bool ToBool(double value) + { + if (value.Equals(1)) + { + return true; + } + + return false; + } + + /// + /// Converts a boolean into an int32. + /// + /// The int32 value. + /// If 1 true, false otherwise. + public static int ToInt32(bool value) + { + if (value) + { + return 1; + } + + return 0; + } + + /// + /// Converts two double values into a percent. + /// + /// + /// + public static double ToPercent(double part, double whole) + { + return part * 100 / whole; + } + + /// + /// Converts an to + /// + /// + /// + public static string ToString(EndPoint endPoint) + { + string value = endPoint.ToString(); + return value[..value.IndexOf(':')]; + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Utils/Encryption.cs b/Parallel.Core/Utils/Encryption.cs new file mode 100644 index 0000000..97a5db0 --- /dev/null +++ b/Parallel.Core/Utils/Encryption.cs @@ -0,0 +1,88 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Security.Cryptography; +using System.Text; + +namespace Parallel.Core.Utils +{ + /// + /// Provides functionality for encryption. This class cannot be inherited. + /// + public static class Encryption + { + /// + /// Converts a string of UTF-8 characters to a base64 string. + /// + /// The string to be encoded. + /// A base64 encoded string. + public static string Encode(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + + byte[] data = Encoding.UTF8.GetBytes(value); + string encoded = Convert.ToBase64String(data); + return encoded; + } + + /// + /// Converts a base64 string to a string of UTF-8 characters. + /// + /// The base64 string to decode. + public static string Decode(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + + byte[] data = Convert.FromBase64String(value); + string decoded = Encoding.UTF8.GetString(data); + return decoded; + } + + /// + /// Encrypts a . + /// + /// The input stream. + /// The output stream. + /// + /// + public static void EncryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp) + { + byte[] salt = HashGenerator.RandomBytes(16); + byte[] iv = HashGenerator.RandomBytes(16); + byte[] derivedKey = HashGenerator.HKDF(masterKey, salt, timestamp.ToISOString(), 32); // 256-bit key + using (Aes aes = Aes.Create()) + { + aes.Key = derivedKey; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + using (CryptoStream cryptoStream = new CryptoStream(output, aes.CreateEncryptor(), CryptoStreamMode.Write)) + { + input.CopyTo(cryptoStream); + } + } + } + + /// + /// Decrypts a . + /// + /// The input stream. + /// The output stream. + /// + /// + /// + /// + public static void DecryptStream(Stream input, Stream output, string masterKey, UnixTime timestamp, byte[] salt, byte[] iv) + { + byte[] derivedKey = HashGenerator.HKDF(masterKey, salt, timestamp.ToISOString(), 32); // 256-bit key + using (Aes aes = Aes.Create()) + { + aes.Key = derivedKey; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + using (CryptoStream cryptoStream = new CryptoStream(input, aes.CreateDecryptor(), CryptoStreamMode.Read)) + { + cryptoStream.CopyTo(output); + } + } + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Utils/Formatter.cs b/Parallel.Core/Utils/Formatter.cs new file mode 100644 index 0000000..411d22d --- /dev/null +++ b/Parallel.Core/Utils/Formatter.cs @@ -0,0 +1,78 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Core.Utils +{ + /// + /// Converts a data type to a formatted string. This class cannot be inherited. + /// + public class Formatter + { + /// + /// Formats a file size with the corresponding data volume. + /// + /// The bytes to convert. + /// The bytes formatted as a string. + 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]}"; + } + + /// + /// Formats a with the corresponding data volume. + /// + /// The bytes to convert. + /// A formatted as a MM/DD/YYYY HH:MM TT. + public static string FromDateTime(DateTime dateTime) + { + return dateTime.ToLocalTime().ToString("g"); + } + + public static string FromTimeSpan(TimeSpan timeSpan) + { + if (timeSpan.TotalDays > 365) + { + int years = (int)Math.Floor(timeSpan.TotalDays / 365); + return years == 1 ? "1 year ago" : $"{years} years ago"; + } + else if (timeSpan.TotalDays > 30) + { + int months = (int)Math.Floor(timeSpan.TotalDays / 30); + return months == 1 ? "1 month ago" : $"{months} months ago"; + } + else if (timeSpan.TotalHours > 24) + { + int days = (int)timeSpan.TotalDays; + return days == 1 ? "1 day ago" : $"{days} days ago"; + } + else if (timeSpan.TotalMinutes > 60) + { + int hours = (int)timeSpan.TotalHours; + return hours == 1 ? "1 hour ago" : $"{hours} hours ago"; + } + else if (timeSpan.TotalSeconds > 60) + { + int minutes = (int)timeSpan.TotalMinutes; + return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago"; + } + else + { + return "Just now"; + } + } + + public static string FromTimeSpan(DateTime dateTime) + { + return dateTime.ToString("dd:HH:mm:ss.fff"); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Utils/HashGenerator.cs b/Parallel.Core/Utils/HashGenerator.cs new file mode 100644 index 0000000..43614f4 --- /dev/null +++ b/Parallel.Core/Utils/HashGenerator.cs @@ -0,0 +1,80 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Security.Cryptography; +using System.Text; + +namespace Parallel.Core.Utils +{ + /// + /// Provides functionality for generating random hashes. This class cannot be inherited. + /// + public static class HashGenerator + { + public static byte[] RandomBytes(int length) + { + byte[] bytes = new byte[length]; + using RandomNumberGenerator rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return bytes; + } + + public static byte[] HKDF(string masterKey, byte[] salt, string info, int length) + { + using (HMACSHA256 hmac = new HMACSHA256(Encoding.UTF8.GetBytes(masterKey))) + { + byte[] prk = hmac.ComputeHash(salt); + byte[] infoBytes = Encoding.UTF8.GetBytes(info); + byte[] output = new byte[length]; + byte[] previous = new byte[0]; + int iterations = (int)Math.Ceiling((double)length / hmac.HashSize * 8); + + for (int i = 0; i < iterations; i++) + { + byte[] input = new byte[previous.Length + infoBytes.Length + 1]; + Buffer.BlockCopy(previous, 0, input, 0, previous.Length); + Buffer.BlockCopy(infoBytes, 0, input, previous.Length, infoBytes.Length); + input[input.Length - 1] = (byte)(i + 1); + + previous = hmac.ComputeHash(input); + Buffer.BlockCopy(previous, 0, output, i * previous.Length, previous.Length); + } + + Array.Resize(ref output, length); + return output; + } + } + + /// + /// Generates a random hash. + /// + /// + /// + /// + public static string GenerateHash(int length, bool lowercase = false) + { + return RandomNumberGenerator.GetHexString(length, lowercase); + } + + /// + /// Computes a SHA1 hash from a string. + /// + /// The string to hash. + /// A hash as a string. + public static string CreateSHA1(string value) + { + ArgumentException.ThrowIfNullOrEmpty(value); + return Convert.ToHexString(SHA1.HashData(Encoding.ASCII.GetBytes(value))).ToLower(); + } + + /// + /// Computes a SHA256 hash from a string. + /// + /// The string to hash. + /// A hash as a string. + public static string CreateSHA256(string value) + { + ArgumentException.ThrowIfNullOrEmpty(value); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLower(); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Utils/ProcessMonitor.cs b/Parallel.Core/Utils/ProcessMonitor.cs new file mode 100644 index 0000000..6afa3e2 --- /dev/null +++ b/Parallel.Core/Utils/ProcessMonitor.cs @@ -0,0 +1,128 @@ +// Copyright 2025 Kyle Ebbinga + +using System.Diagnostics; +using System.Timers; + +namespace Parallel.Core.Utils +{ + /// + /// Allows for easy use of monitoring system processes. + /// + public class ProcessMonitor : IDisposable + { + private readonly Process _process; + private System.Timers.Timer? _timer; + private DateTime _oldTime = DateTime.UtcNow; + private TimeSpan _oldUsage; + + /// + /// The timestamp of when the associated process started running. + /// + public DateTime StartTime { get; private set; } + + /// + /// The duration of time that the associated process has been running. + /// + public TimeSpan Uptime { get; private set; } + + /// + /// The percent of cpu currently being utilized by the associated process. + /// + public double CpuUsage { get; private set; } + + /// + /// The highest recorded percent of cpu utilized by the associated process. + /// + public double PeakCpuUsage { get; private set; } + + /// + /// The amount of memory, in bytes, being used by the associated process. + /// + public double RamUsage { get; private set; } + + /// + /// The highest recorded amount of memory, in bytes, being used by the associated process. + /// + public double PeakRamUsage { get; private set; } + + /// + /// Initializes new instance of the class with the current process. + /// + public ProcessMonitor() + { + _process = Process.GetCurrentProcess(); + StartTime = _process.StartTime; + } + + /// + /// Initializes new instance of the class with a provided process. + /// + /// The process to monitor. + public ProcessMonitor(Process process) + { + _process = process; + StartTime = process.StartTime; + } + + + private void Timer_Elapsed(object sender, ElapsedEventArgs e) + { + Refresh(); + } + + /// + /// Starts refreshing the system process information of the associated with an interval between updates. + /// + /// The time, in milliseconds, in which to request a system update. + public void Start(double interval = 5000) + { + _oldTime = DateTime.UtcNow; + _oldUsage = _process.TotalProcessorTime; + _timer = new System.Timers.Timer(); + _timer.Elapsed += Timer_Elapsed; + _timer.Interval = interval; + _timer.Start(); + } + + /// + /// Stops refreshing the system process information. + /// + public void Stop() + { + _timer?.Stop(); + } + + /// + /// Refreshes the information cached in the associated process. + /// Its important to note that CPU utilization works by calculating the amount of time spent processing in a certain timespan. + /// Calling this function more frequently will provide a more accurate CPU utilization result. + /// + public void Refresh() + { + _process.Refresh(); + Uptime = DateTime.Now - _process.StartTime; + + DateTime endTime = DateTime.UtcNow; + TimeSpan endUsage = _process.TotalProcessorTime; + + double timeMs = (endTime - _oldTime).TotalMilliseconds; + double usageMs = (endUsage - _oldUsage).TotalMilliseconds; + + _oldTime = endTime; + _oldUsage = endUsage; + + CpuUsage = (usageMs / (Environment.ProcessorCount * timeMs)) * 100; + RamUsage = _process.PrivateMemorySize64; + + if (CpuUsage > PeakCpuUsage) PeakCpuUsage = CpuUsage; + if (RamUsage > PeakRamUsage) PeakRamUsage = RamUsage; + } + + /// + public void Dispose() + { + _process?.Dispose(); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/Parallel.Core/Utils/UnixTime.cs b/Parallel.Core/Utils/UnixTime.cs new file mode 100644 index 0000000..2b99d7c --- /dev/null +++ b/Parallel.Core/Utils/UnixTime.cs @@ -0,0 +1,305 @@ +// Copyright 2025 Kyle Ebbinga + +using Newtonsoft.Json; +using Parallel.Core.Extensions.Json; + +namespace Parallel.Core.Utils +{ + /// + /// Represents an instant in time, expressed as either the seconds, or milliseconds since the Unix epoch. + /// + [JsonConverter(typeof(UnixTimeConverter))] + public readonly struct UnixTime + { + private readonly DateTime _timestamp; + + #region Constants + + /// + /// Represents the largest possible value of . + /// + public const long MaxValue = 9223372036854775807; + + /// + /// Represents the smallest possible value of . + /// + public const long MinValue = -9223372036854775808; + + /// + /// A year in the corresponding milliseconds. + /// + public const long Year = 31104000000; + + /// + /// A month in the corresponding milliseconds. + /// + public const long Month = 2592000000; + + /// + /// A week in the corresponding milliseconds. + /// + public const long Week = 604800000; + + /// + /// A day in the corresponding milliseconds. + /// + public const long Day = 86400000; + + /// + /// An hour in the corresponding milliseconds. + /// + public const long Hour = 3600000; + + /// + /// A minute in the corresponding milliseconds. + /// + public const long Minute = 60000; + + /// + /// A second in the corresponding milliseconds. + /// + public const long Second = 1000; + + #endregion + + #region Properties + + /// + /// Gets a object that is set to current time since epoch. + /// + public static UnixTime Now => new(DateTime.Now); + + /// + /// Gets the value of the current structure expressed as whole milliseconds. + /// + public long TotalMilliseconds => (long)_timestamp.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + + /// + /// Gets the value of the current structure expressed as whole and fractional seconds. + /// + public double TotalSeconds => _timestamp.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; + + + /// + /// Gets the value of the current structure expressed as whole and fractional minutes. + /// + public double TotalMinutes => _timestamp.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMinutes; + + /// + /// Gets the value of the current structure expressed as whole and fractional hours. + /// + public double TotalHours => _timestamp.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalHours; + + /// + /// Gets the value of the current structure expressed as whole and fractional days. + /// + public double TotalDays => _timestamp.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalDays; + + #endregion + + #region Constructors + + /// + /// Initializes new instance of the structure with epoch time. + /// + public UnixTime() + { + _timestamp = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + /// + /// Initializes new instance of the structure to the specified . + /// + /// The specified . + public UnixTime(DateTime datetime) + { + _timestamp = datetime.ToUniversalTime(); + } + + /// + /// Converts a string representation of a date and time to its equivalent. + /// + /// + /// An object equivalent to the date and time of the string. + /// + /// + public static UnixTime Parse(string s) + { + ArgumentNullException.ThrowIfNullOrEmpty(s); + return new UnixTime(DateTime.Parse(s).ToUniversalTime()); + } + + /// + /// Returns a that represents the milliseconds since epoch. + /// + /// + public static UnixTime FromTicks(long value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(value)); + } + + /// + /// Returns a that represents the milliseconds since epoch. + /// + /// + public static UnixTime FromMilliseconds(double value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(value)); + } + + /// + /// Returns a that represents the seconds since epoch. + /// + /// + public static UnixTime FromSeconds(double value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(value)); + } + + /// + /// Returns a that represents the minutes since epoch. + /// + /// + public static UnixTime FromMinutes(double value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(value)); + } + + /// + /// Returns a that represents the hours since epoch. + /// + /// + public static UnixTime FromHours(double value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddHours(value)); + } + + /// + /// Returns a that represents the days since epoch. + /// + /// + public static UnixTime FromDays(double value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddDays(value)); + } + + /// + /// Returns a that represents the months since epoch. + /// + /// + public static UnixTime FromMonths(int value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(value)); + } + + /// + /// Returns a that represents the years since epoch. + /// + /// + public static UnixTime FromYears(int value) + { + return new UnixTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddYears(value)); + } + + #endregion + + #region Methods + + /// + /// Returns a new that adds the specified to the value of this instance. + /// + /// + /// An object whose value is equal to the Unix time represented by this instance. + public UnixTime Add(TimeSpan time) + { + return new UnixTime(_timestamp.Add(time)); + } + + /// + /// Returns a new that adds the specified to the value of this instance. + /// + /// + /// An object whose value is equal to the Unix time represented by this instance. + public UnixTime Add(UnixTime time) + { + return Add(TimeSpan.FromMilliseconds(time.TotalMilliseconds)); + } + + /// + /// Returns a new that subtracts the specified to the value of this instance. + /// + /// + /// An object whose value is equal to the Unix time represented by this instance. + public UnixTime Subtract(TimeSpan time) + { + return new UnixTime(_timestamp.Subtract(time)); + } + + /// + /// Returns a new that subtracts the specified to the value of this instance. + /// + /// + /// An object whose value is equal to the Unix time represented by this instance. + public UnixTime Subtract(UnixTime time) + { + return Subtract(TimeSpan.FromMilliseconds(time.TotalMilliseconds)); + } + + /// + /// Converts the value of the current to a . + /// + /// A equivalent whose property is set to . + public DateTime ToUniversalTime() + { + return _timestamp.ToUniversalTime(); + } + + /// + /// Converts the value of the current to a . + /// + /// A equivalent whose property is set to . + public DateTime ToLocalTime() + { + return _timestamp.ToLocalTime(); + } + + /// + /// Converts the value of the current to a since January 1st, 1970. + /// + /// A structure equivalent to the current . + public TimeSpan ToTimeSpan() + { + return TimeSpan.FromMilliseconds(TotalMilliseconds); + } + + /// + /// Converts the current to the equivalent milliseconds since epoch. + /// + /// The string representation of milliseconds since epoch. + public override string ToString() + { + return TotalMilliseconds.ToString(); + } + + /// + /// Converts the current to the equivalent representation specified by format. + /// + /// + /// The string representation specified by the . + public string ToString(string format) + { + return _timestamp.ToString(format); + } + + /// + /// Converts the current to its equivalent representation specified by the ISO 8601 format. + /// + /// The string representation specified by the ISO 8601 format. + public string ToISOString() + { + return _timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + } + + #endregion + } +} \ No newline at end of file diff --git a/Parallel.Service/Parallel.Service.csproj b/Parallel.Service/Parallel.Service.csproj new file mode 100644 index 0000000..bfde18a --- /dev/null +++ b/Parallel.Service/Parallel.Service.csproj @@ -0,0 +1,40 @@ + + + + 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 new file mode 100644 index 0000000..8950aa8 --- /dev/null +++ b/Parallel.Service/Program.cs @@ -0,0 +1,66 @@ +// 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 new file mode 100644 index 0000000..118dcf5 --- /dev/null +++ b/Parallel.Service/RequestHandler.cs @@ -0,0 +1,72 @@ +// 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 + { + private readonly Dictionary _requests; + + 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 all requests registered. + if (_requests.Count == types.Length) + { + Log.Information($"Successfully registered all {types.Length} requests"); + } + else + { + 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) + { + if (!_requests.TryGetValue(request.Name, out Type? requestType)) + { + Log.Warning($"Unknown command: {request.Name}"); + throw new InvalidOperationException($"Unknown command: {request.Name}"); + } + + // Instantiate the request object + object? instance = Activator.CreateInstance(requestType); + if (instance is not IRequest requestInstance) + throw new InvalidOperationException($"Type '{requestType.Name}' does not implement IRequest."); + + // Map parameters to object properties + foreach (PropertyInfo? prop in requestType.GetProperties()) + { + if (request.Parameters.TryGetValue(prop.Name, out string? value)) + { + object? converted = Convert.ChangeType(value, prop.PropertyType); + prop.SetValue(instance, converted); + } + } + + // 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}"); + throw new InvalidOperationException($"Validation failed: {errors}"); + } + + return requestInstance; + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Requests/BaseRequest.cs b/Parallel.Service/Requests/BaseRequest.cs new file mode 100644 index 0000000..52dfe05 --- /dev/null +++ b/Parallel.Service/Requests/BaseRequest.cs @@ -0,0 +1,28 @@ +// 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); + } + + protected ObjectResponse Success() + { + return new ObjectResponse("Success"); + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Requests/HelpRequest.cs b/Parallel.Service/Requests/HelpRequest.cs new file mode 100644 index 0000000..18c28f4 --- /dev/null +++ b/Parallel.Service/Requests/HelpRequest.cs @@ -0,0 +1,17 @@ +// Copyright 2025 Kyle Ebbinga + +using System.ComponentModel; +using Parallel.Core.Net.Sockets; +using Parallel.Service.Responses; + +namespace Parallel.Service.Requests +{ + [Description("Lists all avalible requests to the server.")] + public class HelpRequest : BaseRequest + { + public override Task ExecuteAsync() + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Requests/IRequest.cs b/Parallel.Service/Requests/IRequest.cs new file mode 100644 index 0000000..88e6286 --- /dev/null +++ b/Parallel.Service/Requests/IRequest.cs @@ -0,0 +1,19 @@ +// 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/LoginRequest.cs b/Parallel.Service/Requests/LoginRequest.cs new file mode 100644 index 0000000..20e4f75 --- /dev/null +++ b/Parallel.Service/Requests/LoginRequest.cs @@ -0,0 +1,22 @@ +// Copyright 2025 Kyle Ebbinga + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Parallel.Core.Net.Sockets; +using Parallel.Service.Responses; + +namespace Parallel.Service.Requests +{ + [Description("Logins into the server.")] + public class LoginRequest : BaseRequest + { + [Required] public string Username { get; set; } + + [Required] public string Password { get; set; } + + public override Task ExecuteAsync() + { + return Task.FromResult(Success()); + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Requests/PingRequest.cs b/Parallel.Service/Requests/PingRequest.cs new file mode 100644 index 0000000..643ef2a --- /dev/null +++ b/Parallel.Service/Requests/PingRequest.cs @@ -0,0 +1,15 @@ +// Copyright 2025 Kyle Ebbinga + +using Parallel.Core.Net.Sockets; +using Parallel.Service.Responses; + +namespace Parallel.Service.Requests +{ + public class PingRequest : BaseRequest + { + public override Task ExecuteAsync() + { + return Task.FromResult(Success()); + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Responses/IResponse.cs b/Parallel.Service/Responses/IResponse.cs new file mode 100644 index 0000000..84473ba --- /dev/null +++ b/Parallel.Service/Responses/IResponse.cs @@ -0,0 +1,8 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Service.Responses +{ + public interface IResponse + { + } +} \ No newline at end of file diff --git a/Parallel.Service/Responses/MessageResponse.cs b/Parallel.Service/Responses/MessageResponse.cs new file mode 100644 index 0000000..5f6c383 --- /dev/null +++ b/Parallel.Service/Responses/MessageResponse.cs @@ -0,0 +1,14 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Service.Responses +{ + public class MessageResponse + { + public string Message { get; set; } + + public MessageResponse(string message) + { + Message = message; + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Responses/ObjectResponse.cs b/Parallel.Service/Responses/ObjectResponse.cs new file mode 100644 index 0000000..20ff8ce --- /dev/null +++ b/Parallel.Service/Responses/ObjectResponse.cs @@ -0,0 +1,14 @@ +// Copyright 2025 Kyle Ebbinga + +namespace Parallel.Service.Responses +{ + public sealed class ObjectResponse : IResponse + { + public object? Data { get; } + + public ObjectResponse(object? data) + { + Data = data; + } + } +} \ No newline at end of file diff --git a/Parallel.Service/Services/FileBackupService.cs b/Parallel.Service/Services/FileBackupService.cs new file mode 100644 index 0000000..2c91ca5 --- /dev/null +++ b/Parallel.Service/Services/FileBackupService.cs @@ -0,0 +1,14 @@ +// 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 new file mode 100644 index 0000000..e1abcc1 --- /dev/null +++ b/Parallel.Service/Services/FileCleanupService.cs @@ -0,0 +1,14 @@ +// 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 new file mode 100644 index 0000000..2e1c644 --- /dev/null +++ b/Parallel.Service/Services/LoggingService.cs @@ -0,0 +1,28 @@ +// 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 new file mode 100644 index 0000000..ceabb94 --- /dev/null +++ b/Parallel.Service/Services/TcpRequestService.cs @@ -0,0 +1,92 @@ +// 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 handlerTask = Task.Run(() => AcceptRequestAsync(handler).ContinueWith(t => + { + t.Dispose(); + }, token), token); + + _requestPool.Add(handlerTask); + } + + private async Task AcceptRequestAsync(ISocketHandler handler) + { + ServerRequest request = handler.Parse(); + Log.Debug($"Received request '{handler.RawData}' from '{handler.RemoteEndPoint}' ({_requestPool.Count} active request{(_requestPool.Count == 1 ? string.Empty : "s")})"); + IRequest requestInstance = _requests.CreateNew(request); + + IResponse response = await requestInstance.ExecuteAsync(); + await handler.RespondAsync(response); + Log.Debug($"Responding to '{handler.RemoteEndPoint}' with '{JsonConvert.SerializeObject(response)}' ({_requestPool.Count} active request{(_requestPool.Count == 1 ? string.Empty : "s")})"); + } + + 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 new file mode 100644 index 0000000..3d10d00 --- /dev/null +++ b/Parallel.Service/Utils/UdpReporting.cs @@ -0,0 +1,24 @@ +// 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 new file mode 100644 index 0000000..27a395d Binary files /dev/null and b/Parallel.Service/parallel-red.ico differ diff --git a/Parallel.sln b/Parallel.sln new file mode 100644 index 0000000..f5b080f --- /dev/null +++ b/Parallel.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parallel.Core", "Parallel.Core\Parallel.Core.csproj", "{1391ED00-10D5-417A-982B-0A9A6A2295D4}" +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 + 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 + {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 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..c398c58 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Parallel +[![.NET](https://img.shields.io/github/actions/workflow/status/TheGuitarleader/Parallel/dotnet.yml?&style=for-the-badge)](https://github.com/TheGuitarleader/Parallel/actions/workflows/dotnet.yml) + +Your files under your control. + +## What is Parallel? + +Parallel is a modular, cross-platform file backup and synchronization tool built for people who want full control of their files. It ditches the cloud-first assumptions and gives you full transparency, and control over the how, when, and where your files move. No contracts, no vendor lock-ins, no silent overwrites. Just clean, dependable syncing on your terms. + +Parallel was originally built to handle **terabytes of data** because Dropbox simply couldn’t. When commercial cloud services hit their limits, Parallel stepped in to offer **unbounded scale**, **local-first logic**, and **configurable workflows** that respect your storage, your bandwidth, and your rules. + +## Why Parallel? + +Parallel is completely free and open source. You provide the storage, and Parallel handles the sync. Whether it’s an external drive, a NAS, a remote SSH server, or an S3-compatible cloud like [Storj](https://www.storj.io/) or [Wasabi](https://wasabi.com/), Parallel adapts to what you own. + +Your computer already gives you enough to fight with — your files don't have to be one of them. Parallel keeps backups simple, transparent, and under your control with no cloud drama. + +| Feature | **Parallel** | **Dropbox** | **OneDrive** | **iCloud** | **File History (Windows)** | +|-------------------------------|--------------|-------------|--------------|------------|-----------------------------| +| **Open Source** | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No +| **Local-first** | ✅ Always | ❌ Cloud-first | ❌ Cloud-first | ⚠️ Hybrid (Apple ecosystem) | ✅ Yes +| **Modular storage options** | ✅ Any (NAS, SSH, S3) | ❌ Vendor-locked | ❌ Vendor-locked | ❌ Vendor-locked | ❌ Local only +| **Compression** | ✅ Always | ❌ No | ❌ No | ❌ No | ❌ No +| **Encryption** | ✅ Optional | ❌ Vendor-controlled | ❌ Vendor-controlled | ❌ Vendor-controlled | ⚠️ Depends on drive encryption +| **Cross-platform** | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Apple-centric | ❌ Windows only +| **Free to use** | ✅ Always | ⚠️ 2GB free | ⚠️ 5GB free | ⚠️ 5GB free | ✅ Yes +| **Max storage** | ✅ Unlimited | ⚠️ 2GB (free), 3TB (personal), 15TB (enterprise) | ⚠️ 5TB (personal), 25TB (enterprise) | ⚠️ 5GB–12TB (paid tiers) | ⚠️ Limited by drive size + + + +## 📦 Installation + +> Coming soon — Parallel is currently in active development. Stay tuned for install instructions, binaries, and package manager support. + +## 🧪 Status + +Parallel is currently in early development. Expect rapid iteration, breaking changes, and lots of modular experimentation. Contributions, feedback, and testing are welcome! + +## 🤝 Contributing + +Want to help shape the future of file storage? Open an issue, submit a pull request, or reach out with ideas. Parallel is built for transparency — and that includes its development. + +## 📄 License + +Parallel is licensed under the **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)** license. + +* ✅ You can **use, modify, and share** this code freely. +* ✅ You can **build on it**, fork it, and remix it — as long as you credit the original. +* ❌ You **cannot sell** this code or use it in commercial products. +* ❌ You **cannot claim it as your own** or strip attribution. +* 🔁 If you make changes and share them, you must use the same license + +## 💬 Contact + +For questions and ideas, reach out via our [GitHub Issues](https://github.com/TheGuitarleader/Parallel/issues). +