diff --git a/zero.Core/FileStorage/FileSystemException.cs b/zero.Core/FileStorage/FileSystemException.cs new file mode 100644 index 00000000..fc9d39b0 --- /dev/null +++ b/zero.Core/FileStorage/FileSystemException.cs @@ -0,0 +1,10 @@ +namespace zero.FileStorage; + +public class FileSystemException : Exception +{ + public FileSystemException() { } + + public FileSystemException(string message) : base(message) { } + + public FileSystemException(string message, Exception innerException) : base(message, innerException) { } +} \ No newline at end of file diff --git a/zero.Core/FileStorage/IFileMeta.cs b/zero.Core/FileStorage/IFileMeta.cs new file mode 100644 index 00000000..48edc66c --- /dev/null +++ b/zero.Core/FileStorage/IFileMeta.cs @@ -0,0 +1,39 @@ +namespace zero.FileStorage; + +public interface IFileInfo +{ + /// + /// Full path of the item within the file system + /// + string Path { get; } + + /// + /// Name of the item + /// + string Name { get; } + + /// + /// Full path to the directory containing the item + /// + string DirectoryPath { get; } + + /// + /// Length of the file in bytes + /// + long Length { get; } + + /// + /// Date when the item was last modified + /// + DateTimeOffset LastModifiedDate { get; } + + /// + /// Whether this item is directory or a file + /// + bool IsDirectory { get; } + + /// + /// Additional properties + /// + Dictionary Properties { get; } +} diff --git a/zero.Core/FileStorage/IFileSystem.cs b/zero.Core/FileStorage/IFileSystem.cs new file mode 100644 index 00000000..6d34bcc5 --- /dev/null +++ b/zero.Core/FileStorage/IFileSystem.cs @@ -0,0 +1,56 @@ +using System.IO; + +namespace zero.FileStorage; + +public interface IFileSystem +{ + /// + /// Get information of a file + /// + Task GetFileInfo(string path, CancellationToken cancellationToken = default); + + /// + /// Returns a readable stream for a file + /// + Task StreamFile(string path, CancellationToken cancellationToken = default); + + /// + /// Creates a file at the given path + /// + Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default); + + /// + /// Deletes a file at the given path. This method returns if the file does not exist. + /// + Task DeleteFile(string path, CancellationToken cancellationToken = default); + + /// + /// Moves a file to another path (can also be used to rename resources) + /// + Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default); + + /// + /// Copies a file to another path + /// + Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default); + + /// + /// Get information of a directory + /// + Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default); + + /// + /// Get all items within a directory + /// + IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default); + + /// + /// Tries to create a directory at the given path. This method returns if the directory already exists. + /// + Task CreateDirectory(string path, CancellationToken cancellationToken = default); + + /// + /// Deletes a directory at the given path. This method returns if the directory does not exist. + /// + Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/zero.Core/FileStorage/PhysicalFileMeta.cs b/zero.Core/FileStorage/PhysicalFileMeta.cs new file mode 100644 index 00000000..13aa767f --- /dev/null +++ b/zero.Core/FileStorage/PhysicalFileMeta.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.FileProviders; + +namespace zero.FileStorage; + +public class PhysicalFileMeta : IFileMeta +{ + /// + public string Path { get; } + + /// + public string Name { get; } + + /// + public string DirectoryPath { get; } + + /// + public long Length { get; } + + /// + public DateTimeOffset LastModifiedDate { get; } + + /// + public bool IsDirectory { get; } + + /// + public Dictionary Properties { get; } + + + public PhysicalFileMeta(IFileInfo fileInfo, string path) + { + Path = path; + Name = fileInfo.Name; + DirectoryPath = path.Substring(0, path.Length - fileInfo.Name.Length).TrimEnd('/'); + LastModifiedDate = fileInfo.LastModified; + Length = fileInfo.Length; + IsDirectory = fileInfo.IsDirectory; + Properties = new(); + } +} \ No newline at end of file diff --git a/zero.Core/FileStorage/PhysicalFileSystem.cs b/zero.Core/FileStorage/PhysicalFileSystem.cs new file mode 100644 index 00000000..8ef5d01a --- /dev/null +++ b/zero.Core/FileStorage/PhysicalFileSystem.cs @@ -0,0 +1,275 @@ +using Microsoft.Extensions.FileProviders.Physical; +using System.IO; + +namespace zero.FileStorage; + +public class PhysicalFileSystem : IFileSystem +{ + readonly string basePath; + + + public PhysicalFileSystem(string basePath) + { + this.basePath = basePath; + } + + + /// + public Task GetFileInfo(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + PhysicalFileInfo fileInfo = new(new FileInfo(resolvedPath)); + + if (!fileInfo.Exists) + { + return Task.FromResult(default); + } + + return Task.FromResult(new PhysicalFileMeta(fileInfo, path)); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file info for path '{path}'", ex); + } + } + + + /// + public Task StreamFile(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + if (resolvedPath.IsNullOrEmpty() || !File.Exists(resolvedPath)) + { + throw new FileSystemException($"The path '{path}' does not exist in the file system"); + } + + return Task.FromResult(File.OpenRead(resolvedPath)); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file stream for path '{path}'", ex); + } + } + + + /// + public async Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + if ((!overwrite && File.Exists(resolvedPath)) || Directory.Exists(resolvedPath)) + { + throw new FileSystemException($"A file/directory at the path '{path}' already existsin the file system"); + } + + string directoryPath = Path.GetDirectoryName(resolvedPath); + Directory.CreateDirectory(directoryPath); + + FileInfo fileInfo = new(resolvedPath); + using var outputStream = fileInfo.Create(); + await fileStream.CopyToAsync(outputStream, cancellationToken); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get file stream for path '{path}'", ex); + } + } + + + /// + public Task DeleteFile(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + File.Delete(resolvedPath); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not delete file for path '{path}'", ex); + } + } + + + /// + public Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default) + { + try + { + string resolvedOldPath = ResolvePath(oldPath); + string resolvedNewPath = ResolvePath(newPath); + + if (!File.Exists(resolvedOldPath)) + { + throw new FileSystemException($"The path '{oldPath}' does not exist in the file system"); + } + + if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath)) + { + throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system"); + } + + File.Move(resolvedOldPath, resolvedNewPath); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not move file from path '{oldPath}' to '{newPath}'", ex); + } + } + + + /// + public Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default) + { + try + { + string resolvedOldPath = ResolvePath(oldPath); + string resolvedNewPath = ResolvePath(newPath); + + if (!File.Exists(resolvedOldPath)) + { + throw new FileSystemException($"The path '{oldPath}' does not exist in the file system"); + } + + if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath)) + { + throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system"); + } + + File.Copy(resolvedOldPath, resolvedNewPath); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not copy file from path '{oldPath}' to '{newPath}'", ex); + } + } + + + /// + public Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default) => GetFileInfo(path, cancellationToken); + + + /// + public IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + List results = new(); + SearchOption searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + + if (!Directory.Exists(resolvedPath)) + { + return results.ToAsyncEnumerable(); + } + + results.AddRange(Directory.GetDirectories(resolvedPath, "*", searchOption).Select(f => + { + PhysicalDirectoryInfo fileSystemInfo = new(new DirectoryInfo(f)); + return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(basePath.Length))); + })); + + results.AddRange(Directory.GetFiles(resolvedPath, "*", searchOption).Select(f => + { + PhysicalFileInfo fileSystemInfo = new(new FileInfo(f)); + return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(basePath.Length))); + })); + + return results.ToAsyncEnumerable(); + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not get directory content for path '{path}'", ex); + } + } + + + /// + public Task CreateDirectory(string path, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + + if (File.Exists(resolvedPath)) + { + throw new FileSystemException($"A file at the path '{path}' already exists in the file system"); + } + + if (!Directory.Exists(resolvedPath)) + { + Directory.CreateDirectory(resolvedPath); + } + + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not create directory at path '{path}'", ex); + } + } + + + /// + public Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default) + { + try + { + string resolvedPath = ResolvePath(path); + Directory.Delete(resolvedPath, recursive); + return Task.CompletedTask; + } + catch (Exception ex) when (ex is not FileSystemException) + { + throw new FileSystemException($"Could not delete directory at path '{path}'", ex); + } + } + + + /// + /// Creates a fully-usable absolte path from the given path + /// + protected string ResolvePath(string path) + { + try + { + if (path.IsNullOrWhiteSpace()) + { + return basePath; + } + + // normalize path + path = path.Replace('\\', '/').FullTrim().Trim('/'); + + string physicalPath = Path.Combine(basePath, path); + + // do not allow paths which are outside this file system + // the boundaries are set be the basePath which is assigned in the file system constructor + if (!Path.GetFullPath(physicalPath).StartsWith(basePath, StringComparison.InvariantCultureIgnoreCase)) + { + throw new FileSystemException($"The path '{path}' is outside the file system"); + } + + return physicalPath; + } + catch (FileSystemException) + { + throw; + } + catch (Exception ex) + { + throw new FileSystemException($"Could not resolve path '{path}'", ex); + } + } +}