commit 631d47ddf9ed1a0f2530e5a29415c4c90d2a2190 Author: Mike Phares Date: Sat May 10 13:31:06 2025 -0700 Initialize diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1424949 --- /dev/null +++ b/.gitignore @@ -0,0 +1,335 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +.vscode/Helper/** + +.kanbn + +.vscode/.UserSecrets/secrets.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..405cd6e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug/net8.0/FileExposer.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)", + "uriFormat": "%s/swagger/index.html" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/mklink.md b/.vscode/mklink.md new file mode 100644 index 0000000..ed209f2 --- /dev/null +++ b/.vscode/mklink.md @@ -0,0 +1,5 @@ +# mklink + +```bash 1746813221432 = 638824100214320000 = 2025-2.Spring = Fri May 09 2025 10:53:41 GMT-0700 (Mountain Standard Time) +mklink /J "L:\DevOps\EAF-Mesa-Integration\file-exposer\.vscode\.UserSecrets" "C:\Users\phares\AppData\Roaming\Microsoft\UserSecrets\96873816-1a56-4560-868c-01f41d9f118e" +``` diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..28a2f72 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,123 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/FileExposer.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/FileExposer.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/FileExposer.csproj" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "User Secrets Init", + "command": "dotnet", + "type": "process", + "args": [ + "user-secrets", + "-p", + "${workspaceFolder}/FileExposer.csproj", + "init" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "User Secrets Set", + "command": "dotnet", + "type": "process", + "args": [ + "user-secrets", + "-p", + "${workspaceFolder}/FileExposer.csproj", + "set", + "_UserSecretsId", + "96873816-1a56-4560-868c-01f41d9f118e" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "Format", + "command": "dotnet", + "type": "process", + "args": [ + "format", + "--report", + ".vscode", + "--verbosity", + "detailed", + "--severity", + "warn" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "Format-Whitespaces", + "command": "dotnet", + "type": "process", + "args": [ + "format", + "whitespace" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "Publish AOT", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "-r", + "win-x64", + "-c", + "Release", + "-p:PublishAot=true", + "${workspaceFolder}/FileExposer.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "File-Folder-Helper AOT s X Day-Helper-2025-03-20", + "type": "shell", + "command": "L:/DevOps/Mesa_FI/File-Folder-Helper/bin/Release/net8.0/win-x64/publish/File-Folder-Helper.exe", + "args": [ + "s", + "X", + "L:/DevOps/EAF-Mesa-Integration/file-exposer", + "Day-Helper-2025-03-20", + "false", + "4" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/ApiControllers/FileController.cs b/ApiControllers/FileController.cs new file mode 100644 index 0000000..425bb11 --- /dev/null +++ b/ApiControllers/FileController.cs @@ -0,0 +1,26 @@ +// using System.Collections.Generic; +// using System.IO; +// using System.Threading.Tasks; +// using Microsoft.AspNetCore.Http; +// using Microsoft.AspNetCore.Mvc; + +// [ApiController] +// [Route("api/[controller]")] +// https://brokul.dev/sending-files-and-additional-data-using-httpclient-in-net-core +// public class FileController : Controller +// { +// [HttpPatch] +// [ApiExplorerSettings(IgnoreApi = true)] +// public IActionResult Upload([FromForm] IFormFileCollection formFiles, [FromForm] List data) +// { +// foreach (IFormFile formFile in formFiles) +// { +// string st = Path.Combine("d:/Tmp/phares/VisualStudioCode/", formFile.FileName); +// FileStream fileStream = new(st, FileMode.CreateNew); +// Task task = formFile.CopyToAsync(fileStream); +// task.Wait(); +// fileStream.Dispose(); +// } +// return Ok(); +// } +// } \ No newline at end of file diff --git a/ApiControllers/SyncV1Controller.cs b/ApiControllers/SyncV1Controller.cs new file mode 100644 index 0000000..4f0571e --- /dev/null +++ b/ApiControllers/SyncV1Controller.cs @@ -0,0 +1,68 @@ +using System.IO; +using FileExposer.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace FileExposer.ApiControllers; + +[ApiController] +[Route("api/[controller]")] +public class SyncV1Controller(ISyncV1Repository SyncRepository) : Controller, ISyncV1Controller +{ + + private readonly ISyncV1Repository _SyncRepository = SyncRepository; + + [HttpGet] + public IActionResult Get(string path, long? size = null, long? ticks = null) + { + Get result = _SyncRepository.Get(path, size, ticks); + if (result.Bytes is not null) + return File(result.Bytes, "text/plain"); + if (!string.IsNullOrEmpty(result.JSON)) + return Content(result.JSON, "application/json", System.Text.Encoding.UTF8); + throw new System.NotImplementedException(); + } + + [HttpPost] + public IActionResult Post() + { + string result = _SyncRepository.Post(Request.Body); + return Content(result, "application/json", System.Text.Encoding.UTF8); + } + + [HttpDelete] + public IActionResult Delete(string path, long size, long ticks) + { + _SyncRepository.Delete(path, size, ticks); + return Ok($"{size}-{ticks}"); + } + + [HttpPut] + [ApiExplorerSettings(IgnoreApi = true)] + public IActionResult Put([FromForm] string path, IFormFileCollection formFiles) + { + foreach (IFormFile formFile in formFiles) + _SyncRepository.Put(path, formFile.FileName, formFile.OpenReadStream()); + return Ok(); + } + + [HttpPatch] + [ApiExplorerSettings(IgnoreApi = true)] + public IActionResult Patch([FromForm] string path, [FromForm] IFormFileCollection formFiles) + { + foreach (IFormFile formFile in formFiles) + _SyncRepository.Patch(path, formFile.FileName, formFile.OpenReadStream()); + return Ok(); + } + +} +// string st = "d:/Tmp/phares/VisualStudioCode/z-include-patterns - Copy.nsv"; +// using HttpRequestMessage httpRequestMessage = new(HttpMethod.Patch, page); +// using MultipartFormDataContent multipartFormDataContent = new(); +// multipartFormDataContent.Add(new ByteArrayContent(File.ReadAllBytes(st)), "formFiles", $"RelativePath:Test.txt|Size:4|Ticks:1143241;"); +// multipartFormDataContent.Add(new ByteArrayContent(File.ReadAllBytes(st)), "formFiles", "NewTest.txt"); +// multipartFormDataContent.Add(new ByteArrayContent(File.ReadAllBytes(st)), "formFiles", "NewestTest.txt"); +// multipartFormDataContent.Add(new StringContent(path, "path", path); +// httpRequestMessage.Content = multipartFormDataContent; +// Task httpResponseMessage = httpClient.SendAsync(httpRequestMessage); +// httpResponseMessage.Wait(); \ No newline at end of file diff --git a/FileExposer.csproj b/FileExposer.csproj new file mode 100644 index 0000000..fe2ee60 --- /dev/null +++ b/FileExposer.csproj @@ -0,0 +1,28 @@ + + + SAK + SAK + SAK + SAK + 96873816-1a56-4560-868c-01f41d9f118e + + + true + false + enable + Exe + win-x64;linux-x64 + net8.0 + + + + + + + + + + + + + \ No newline at end of file diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs new file mode 100644 index 0000000..c443ad2 --- /dev/null +++ b/Models/AppSettings.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; + +namespace FileExposer.Models; + +public record AppSettings(FileWatcherConfiguration FileWatcherConfiguration, + SyncConfiguration SyncConfiguration) +{ + + public static AppSettings Get(IConfigurationRoot configurationRoot) + { + AppSettings result; +#pragma warning disable IL3050, IL2026 + FileWatcherConfiguration? fileWatcherConfiguration = configurationRoot.GetSection(nameof(FileWatcherConfiguration)).Get(); + SyncConfiguration? syncConfiguration = configurationRoot.GetSection(nameof(SyncConfiguration)).Get(); +#pragma warning restore IL3050, IL2026 + if (fileWatcherConfiguration is null + || syncConfiguration is null + || fileWatcherConfiguration?.Company is null) + { + List paths = []; + foreach (IConfigurationProvider configurationProvider in configurationRoot.Providers) + { + if (configurationProvider is not Microsoft.Extensions.Configuration.Json.JsonConfigurationProvider jsonConfigurationProvider) + continue; + if (jsonConfigurationProvider.Source.FileProvider is not Microsoft.Extensions.FileProviders.PhysicalFileProvider physicalFileProvider) + continue; + paths.Add(physicalFileProvider.Root); + } + throw new NotSupportedException($"Not found!{Environment.NewLine}{string.Join(Environment.NewLine, paths.Distinct())}"); + } + result = new(fileWatcherConfiguration, + syncConfiguration); + Verify(result); + return result; + } + + private static void Verify(AppSettings appSettings) + { + if (appSettings.FileWatcherConfiguration.MaxDegreeOfParallelism > Environment.ProcessorCount) + throw new Exception($"Environment.ProcessorCount must be larger or equal to {nameof(appSettings.FileWatcherConfiguration.MaxDegreeOfParallelism)}"); + } + + public override string ToString() + { + string result = JsonSerializer.Serialize(this, AppSettingsSourceGenerationContext.Default.AppSettings); + return result; + } + +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(AppSettings))] +internal partial class AppSettingsSourceGenerationContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Models/FileWatcherConfiguration.cs b/Models/FileWatcherConfiguration.cs new file mode 100644 index 0000000..1c252b8 --- /dev/null +++ b/Models/FileWatcherConfiguration.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FileExposer.Models; + +public record FileWatcherConfiguration(string Company, + string Helper, + int MaxDegreeOfParallelism, + string UniformResourceLocator) +{ + + public override string ToString() + { + string result = JsonSerializer.Serialize(this, FileWatcherConfigurationSourceGenerationContext.Default.FileWatcherConfiguration); + return result; + } + +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(FileWatcherConfiguration))] +internal partial class FileWatcherConfigurationSourceGenerationContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Models/Get.cs b/Models/Get.cs new file mode 100644 index 0000000..0c10acd --- /dev/null +++ b/Models/Get.cs @@ -0,0 +1,4 @@ +namespace FileExposer.Models; + +public record Get(string? JSON, + byte[]? Bytes); diff --git a/Models/ISyncV1Controller.cs b/Models/ISyncV1Controller.cs new file mode 100644 index 0000000..c2b722e --- /dev/null +++ b/Models/ISyncV1Controller.cs @@ -0,0 +1,13 @@ +namespace FileExposer.Models; + +public interface ISyncV1Controller +{ + + static string GetRouteName() => nameof(ISyncV1Controller)[1..^10]; + T Post(); + T Put(string path, TT formFiles); + T Patch(string path, TT formFiles); + T Get(string path, long? size, long? ticks); + T Delete(string path, long size, long ticks); + +} \ No newline at end of file diff --git a/Models/ISyncV1Repository.cs b/Models/ISyncV1Repository.cs new file mode 100644 index 0000000..8c09405 --- /dev/null +++ b/Models/ISyncV1Repository.cs @@ -0,0 +1,15 @@ +using System; +using System.IO; + +namespace FileExposer.Models; + +public interface ISyncV1Repository +{ + + string Post(Stream stream); + Get Get(string path, long? size, long? ticks); + void Delete(string path, long size, long ticks); + void Put(string path, string data, Stream stream); + void Patch(string path, string data, Stream stream); + +} \ No newline at end of file diff --git a/Models/Record.cs b/Models/Record.cs new file mode 100644 index 0000000..2326b20 --- /dev/null +++ b/Models/Record.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace FileExposer.Models; + +public record Record(string RelativePath, + long Size, + long Ticks) +{ + + internal static ReadOnlyCollection GetCollection(SyncConfiguration syncConfiguration, string rightDirectory) + { + ReadOnlyCollection results; + Matcher matcher = new(); + string excludePatternsFile = Path.Combine(rightDirectory, syncConfiguration.ExcludePatternsFile); + string includePatternsFile = Path.Combine(rightDirectory, syncConfiguration.IncludePatternsFile); + matcher.AddIncludePatterns(!File.Exists(includePatternsFile) ? ["*"] : File.ReadAllLines(includePatternsFile)); + matcher.AddExcludePatterns(!File.Exists(excludePatternsFile) ? ["System Volume Information"] : File.ReadAllLines(excludePatternsFile)); + results = GetRecords(rightDirectory, matcher); + return results; + } + + private static ReadOnlyCollection GetRecords(string rightDirectory, Matcher matcher) + { + List results = []; + Record record; + FileInfo fileInfo; + string relativePath; + ReadOnlyCollection> collection = GetFilesCollection(rightDirectory, "*", "*"); + foreach (ReadOnlyCollection c in collection) + { + foreach (string f in c) + { + if (!matcher.Match(rightDirectory, f).HasMatches) + continue; + fileInfo = new(f); + if (fileInfo.Length == 0) + continue; + relativePath = Path.GetRelativePath(rightDirectory, fileInfo.FullName); + record = new(RelativePath: relativePath, + Size: fileInfo.Length, + Ticks: fileInfo.LastWriteTime.ToUniversalTime().Ticks); + results.Add(record); + } + } + return results.AsReadOnly(); + } + + private static ReadOnlyCollection> GetFilesCollection(string directory, string directorySearchFilter, string fileSearchFilter) + { + List> results = []; + string[] files; + if (!fileSearchFilter.Contains('*')) + fileSearchFilter = string.Concat('*', fileSearchFilter); + if (!directorySearchFilter.Contains('*')) + directorySearchFilter = string.Concat('*', directorySearchFilter); + if (!Directory.Exists(directory)) + _ = Directory.CreateDirectory(directory); + results.Add(Directory.GetFiles(directory, fileSearchFilter, SearchOption.TopDirectoryOnly).AsReadOnly()); + string[] directories = Directory.GetDirectories(directory, directorySearchFilter, SearchOption.TopDirectoryOnly); + foreach (string innerDirectory in directories) + { + try + { + files = Directory.GetFiles(innerDirectory, fileSearchFilter, SearchOption.AllDirectories); + if (files.Length == 0) + continue; + results.Add(files.AsReadOnly()); + } + catch (UnauthorizedAccessException) + { continue; } + } + return results.AsReadOnly(); + } + +} diff --git a/Models/RelativePath.cs b/Models/RelativePath.cs new file mode 100644 index 0000000..a49b95e --- /dev/null +++ b/Models/RelativePath.cs @@ -0,0 +1,39 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace FileExposer.Models; + +public record RelativePath(string Path, + Record[] Records) +{ + + internal static RelativePath? Get(Stream stream) + { + RelativePath? result; + string? json = GetJson(stream); + result = string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json); + return result; + } + + private static string? GetJson(Stream stream) + { + string? result; + if (!stream.CanRead) + result = null; + else + { + Task task = new StreamReader(stream).ReadToEndAsync(); + result = task.Result; + } + return result; + } + +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(RelativePath))] +public partial class RelativePathSourceGenerationContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Models/Review.cs b/Models/Review.cs new file mode 100644 index 0000000..1fbb0b9 --- /dev/null +++ b/Models/Review.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +namespace FileExposer.Models; + +public record Review(Segment[]? AreEqual, + Segment[]? LeftSideIsNewer, + Segment[]? LeftSideOnly, + Segment[]? NotEqualBut, + Record[]? Records, + Segment[]? RightSideIsNewer, + Segment[]? RightSideOnly) +{ + + internal static Review Get(RelativePath relativePath, ReadOnlyCollection records) + { + Review result; + ReadOnlyCollection areEqual = GetAreEqual(relativePath, records); + ReadOnlyCollection notEqualBut = GetNotEqualBut(relativePath, records); + ReadOnlyCollection leftSideOnly = GetLeftSideOnly(relativePath, records); + ReadOnlyCollection rightSideOnly = GetRightSideOnly(relativePath, records); + ReadOnlyCollection leftSideIsNewer = GetLeftSideIsNewer(relativePath, records); + ReadOnlyCollection rightSideIsNewer = GetRightSideIsNewer(relativePath, records); + result = new(AreEqual: [.. areEqual], + LeftSideIsNewer: [.. leftSideIsNewer], + LeftSideOnly: [.. leftSideOnly], + NotEqualBut: [.. notEqualBut], + Records: null, + RightSideIsNewer: [.. rightSideIsNewer], + RightSideOnly: [.. rightSideOnly]); + return result; + } + + private static ReadOnlyCollection GetAreEqual(RelativePath relativePath, ReadOnlyCollection records) + { + List results = []; + Record? record; + Segment segment; + double totalSeconds; + string? checkDirectory = null; + ReadOnlyDictionary keyValuePairs = GetKeyValuePairs(relativePath); + foreach (Record r in records) + { + if (checkDirectory is null && r.Size == 0 && r.Ticks == 0) + { + checkDirectory = r.RelativePath; + continue; + } + if (r.RelativePath == relativePath.Path) + continue; + if (!keyValuePairs.TryGetValue(r.RelativePath, out record)) + continue; + totalSeconds = new TimeSpan(record.Ticks - r.Ticks).TotalSeconds; + if (record.Size != r.Size || totalSeconds is > 2 or < -2) + continue; + segment = new(Left: r, + LeftDirectory: checkDirectory, + Right: record, + RightDirectory: relativePath.Path); + results.Add(segment); + } + return results.AsReadOnly(); + } + + private static ReadOnlyDictionary GetKeyValuePairs(RelativePath relativePath) => + GetKeyValuePairs(relativePath.Records.AsReadOnly()); + + private static ReadOnlyDictionary GetKeyValuePairs(ReadOnlyCollection records) + { + Dictionary results = []; + foreach (Record record in records) + results.Add(record.RelativePath, record); + return new(results); + } + + private static ReadOnlyCollection GetNotEqualBut(RelativePath relativePath, ReadOnlyCollection records) + { + List results = []; + Record? record; + Segment segment; + double totalSeconds; + string? checkDirectory = null; + ReadOnlyDictionary keyValuePairs = GetKeyValuePairs(relativePath); + foreach (Record r in records) + { + if (checkDirectory is null && r.Size == 0 && r.Ticks == 0) + { + checkDirectory = r.RelativePath; + continue; + } + if (r.RelativePath == relativePath.Path) + continue; + if (!keyValuePairs.TryGetValue(r.RelativePath, out record)) + continue; + if (record.Size == r.Size) + continue; + totalSeconds = new TimeSpan(record.Ticks - r.Ticks).TotalSeconds; + if (totalSeconds is >= 2 or <= -2) + continue; + segment = new(Left: r, + LeftDirectory: checkDirectory, + Right: record, + RightDirectory: relativePath.Path); + results.Add(segment); + } + return results.AsReadOnly(); + } + + private static ReadOnlyCollection GetLeftSideOnly(RelativePath relativePath, ReadOnlyCollection records) + { + List results = []; + Record? record; + Segment segment; + string? checkDirectory = null; + ReadOnlyDictionary keyValuePairs = GetKeyValuePairs(relativePath); + foreach (Record r in records) + { + if (checkDirectory is null && r.Size == 0 && r.Ticks == 0) + { + checkDirectory = r.RelativePath; + continue; + } + if (r.RelativePath == relativePath.Path) + continue; + if (keyValuePairs.TryGetValue(r.RelativePath, out record)) + continue; + segment = new(Left: r, + LeftDirectory: checkDirectory, + Right: record, + RightDirectory: relativePath.Path); + results.Add(segment); + } + return results.AsReadOnly(); + } + + private static ReadOnlyCollection GetRightSideOnly(RelativePath relativePath, ReadOnlyCollection records) + { + List results = []; + Record? record; + Segment segment; + string? checkDirectory = null; + ReadOnlyDictionary keyValuePairs = GetKeyValuePairs(records); + foreach (Record r in relativePath.Records) + { + if (checkDirectory is null && r.Size == 0 && r.Ticks == 0) + { + checkDirectory = r.RelativePath; + continue; + } + if (r.RelativePath == relativePath.Path) + continue; + if (keyValuePairs.TryGetValue(r.RelativePath, out record)) + continue; + segment = new(Left: record, + LeftDirectory: null, + Right: r, + RightDirectory: relativePath.Path); + results.Add(segment); + } + return results.AsReadOnly(); + } + + private static ReadOnlyCollection GetLeftSideIsNewer(RelativePath relativePath, ReadOnlyCollection records) + { + List results = []; + Record? record; + Segment segment; + double totalSeconds; + string? checkDirectory = null; + ReadOnlyDictionary keyValuePairs = GetKeyValuePairs(relativePath); + foreach (Record r in records) + { + if (checkDirectory is null && r.Size == 0 && r.Ticks == 0) + { + checkDirectory = r.RelativePath; + continue; + } + if (r.RelativePath == relativePath.Path) + continue; + if (!keyValuePairs.TryGetValue(r.RelativePath, out record)) + continue; + totalSeconds = new TimeSpan(record.Ticks - r.Ticks).TotalSeconds; + if (totalSeconds is > -2) + continue; + segment = new(Left: r, + LeftDirectory: checkDirectory, + Right: record, + RightDirectory: relativePath.Path); + results.Add(segment); + } + return results.AsReadOnly(); + } + + private static ReadOnlyCollection GetRightSideIsNewer(RelativePath relativePath, ReadOnlyCollection records) + { + List results = []; + Record? record; + Segment segment; + double totalSeconds; + string? checkDirectory = null; + ReadOnlyDictionary keyValuePairs = GetKeyValuePairs(records); + foreach (Record r in relativePath.Records) + { + if (checkDirectory is null && r.Size == 0 && r.Ticks == 0) + { + checkDirectory = r.RelativePath; + continue; + } + if (r.RelativePath == relativePath.Path) + continue; + if (!keyValuePairs.TryGetValue(r.RelativePath, out record)) + continue; + totalSeconds = new TimeSpan(record.Ticks - r.Ticks).TotalSeconds; + if (totalSeconds is > -2) + continue; + segment = new(Left: record, + LeftDirectory: null, + Right: r, + RightDirectory: relativePath.Path); + results.Add(segment); + } + return results.AsReadOnly(); + } + +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(Review))] +public partial class ReviewSourceGenerationContext : JsonSerializerContext +{ +} diff --git a/Models/Segment.cs b/Models/Segment.cs new file mode 100644 index 0000000..6f1957c --- /dev/null +++ b/Models/Segment.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace FileExposer.Models; + +public record Segment(Record? Left, + string? LeftDirectory, + Record? Right, + string RightDirectory); + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(Segment))] +public partial class SegmentSourceGenerationContext : JsonSerializerContext +{ +} diff --git a/Models/SyncConfiguration.cs b/Models/SyncConfiguration.cs new file mode 100644 index 0000000..bf538a2 --- /dev/null +++ b/Models/SyncConfiguration.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FileExposer.Models; + +public record SyncConfiguration(string ExcludePatternsFile, + string IncludePatternsFile, + int SecondThreshold) +{ + + public override string ToString() + { + string result = JsonSerializer.Serialize(this, SyncConfigurationSourceGenerationContext.Default.SyncConfiguration); + return result; + } + +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(SyncConfiguration))] +internal partial class SyncConfigurationSourceGenerationContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/Models/SyncV1Repository.cs b/Models/SyncV1Repository.cs new file mode 100644 index 0000000..f04bbd8 --- /dev/null +++ b/Models/SyncV1Repository.cs @@ -0,0 +1,87 @@ +namespace FileExposer.Models; + +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; + +public class SyncV1Repository(AppSettings appSettings) : ISyncV1Repository +{ + + private readonly string _RepositoryName = nameof(SyncV1Repository)[..^10]; + private readonly AppSettings _AppSettings = appSettings; + + void ISyncV1Repository.Delete(string path, long size, long ticks) + { + FileInfo fileInfo = Verify(path, size, ticks); + if (fileInfo.FullName != path) + throw new Exception("Path must match!"); + File.Delete(path); + } + + Get ISyncV1Repository.Get(string path, long? size, long? ticks) + { + Get result; + if (string.IsNullOrEmpty(path)) + throw new Exception("Path mush be set!"); + if (size is not null && ticks is not null) + { + FileInfo fileInfo = Verify(path, size.Value, ticks.Value); + result = new (JSON: null, Bytes: File.ReadAllBytes(fileInfo.FullName)); + } + else + { + if (File.Exists(path)) + throw new Exception("Must pass size and ticks when passing a file!"); + string directory = Path.GetFullPath(path); + ReadOnlyCollection records = Record.GetCollection(_AppSettings.SyncConfiguration, directory); + RelativePath relativePath = new(Path: path, Records: [.. records]); + result = new (JSON: JsonSerializer.Serialize(relativePath, RelativePathSourceGenerationContext.Default.RelativePath), Bytes: null); + } + return result; + } + + private FileInfo Verify(string path, long size, long ticks) + { + FileInfo fileInfo = new(path); + if (fileInfo.Length != size) + throw new Exception("Size must match!"); + TimeSpan timeSpan = new(fileInfo.LastWriteTime.ToUniversalTime().Ticks - ticks); + if (Math.Abs(timeSpan.TotalSeconds) > _AppSettings.SyncConfiguration.SecondThreshold) + throw new Exception("Ticks must be within the threshold!"); + return fileInfo; + } + + string ISyncV1Repository.Post(Stream stream) + { + string result; + RelativePath? relativePath = RelativePath.Get(stream) ?? + throw new MissingFieldException(); + ReadOnlyCollection records = Record.GetCollection(_AppSettings.SyncConfiguration, relativePath.Path); + if (records.Count == 0) + throw new Exception("No source records"); + Review review = Review.Get(relativePath, records); + result = JsonSerializer.Serialize(review, ReviewSourceGenerationContext.Default.Review); + return result; + } + + void ISyncV1Repository.Patch(string path, string data, Stream stream) + { + string st = "d:/Tmp/phares/VisualStudioCode/z-include-patterns - Copy C.nsv"; + FileStream fileStream = new(st, FileMode.Truncate); + Task task = stream.CopyToAsync(fileStream); + task.Wait(); + fileStream.Dispose(); + } + + void ISyncV1Repository.Put(string path, string data, Stream stream) + { + string st = "d:/Tmp/phares/VisualStudioCode/z-include-patterns - Copy C.nsv"; + FileStream fileStream = new(st, FileMode.CreateNew); + Task task = stream.CopyToAsync(fileStream); + task.Wait(); + fileStream.Dispose(); + } + +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..8bf7869 --- /dev/null +++ b/Program.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using FileExposer.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.WindowsServices; +using Microsoft.Extensions.Logging; + +namespace FileExposer; + +public class Program +{ + + public static int Main(string[] args) + { + ILogger? logger = null; + WebApplicationBuilder webApplicationBuilder = WebApplication.CreateBuilder(args); + _ = webApplicationBuilder.Configuration.AddUserSecrets(); + AppSettings appSettings = AppSettings.Get(webApplicationBuilder.Configuration); + if (string.IsNullOrEmpty(appSettings.FileWatcherConfiguration.Company)) + throw new Exception("Company name must have a value!"); + try + { + _ = webApplicationBuilder.Services.AddSingleton(); + _ = webApplicationBuilder.Services.Configure(options => options.SuppressModelStateInvalidFilter = true); + _ = webApplicationBuilder.Services.AddControllers(); + _ = webApplicationBuilder.Services.AddDistributedMemoryCache(); + _ = webApplicationBuilder.Services.AddHttpClient(); + _ = webApplicationBuilder.Services.AddSingleton(_ => appSettings); + _ = webApplicationBuilder.Services.AddSwaggerGen(); + _ = webApplicationBuilder.Services.AddSession(sessionOptions => + { + sessionOptions.IdleTimeout = TimeSpan.FromSeconds(2000); + sessionOptions.Cookie.HttpOnly = true; + sessionOptions.Cookie.IsEssential = true; + } + ); + if (WindowsServiceHelpers.IsWindowsService()) + { + _ = webApplicationBuilder.Services.AddSingleton(); + _ = webApplicationBuilder.Logging.AddEventLog(settings => + { +#pragma warning disable CA1416 + if (string.IsNullOrEmpty(settings.SourceName)) + settings.SourceName = webApplicationBuilder.Environment.ApplicationName; +#pragma warning restore + }); + } + WebApplication webApplication = webApplicationBuilder.Build(); + if (Debugger.IsAttached) + webApplication.Services.GetRequiredService(); + logger = webApplication.Services.GetRequiredService>(); + _ = webApplication.UseCors(corsPolicyBuilder => corsPolicyBuilder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); + if (!webApplicationBuilder.Environment.IsDevelopment()) + { + _ = webApplication.UseExceptionHandler("/Error"); + _ = webApplication.UseHttpsRedirection(); + _ = webApplication.UseHsts(); + } + else + { + _ = webApplication.UseSwagger(); + _ = webApplication.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Server V1")); + } + _ = webApplication.UseSession(); + _ = webApplication.MapControllers(); + logger.LogInformation("Starting Web Application"); + webApplication.Run(); + return 0; + } + catch (Exception ex) + { + try { logger?.LogCritical(ex, "Host terminated unexpectedly"); } catch (Exception) { } + throw; + } + } + +} \ No newline at end of file