Imports System.IO Imports System.Security.Cryptography Imports System.Text Imports System.Text.RegularExpressions Imports DigitalData.Modules.Logging ''' File ''' 0.0.0.1 ''' 11.10.2018 ''' ''' Module that provides variouse File operations ''' ''' ''' NLog, >= 4.5.8 ''' ''' ''' LogConfig, DigitalData.Module.Logging.LogConfig ''' A LogConfig object ''' ''' ''' ''' ''' ''' ''' Public Class File Private ReadOnly _Logger As Logger Private ReadOnly _LogConfig As LogConfig Private ReadOnly _invalidFilenameChars As String Private ReadOnly _invalidPathChars As String Private Const REGEX_CLEAN_FILENAME As String = "[\\/:""<>|\b\0\r\n\t]" Private Const REGEX_CLEAN_PATH As String = "[""<>|\b\0\r\n\t]" ' The limit enforced by windows for filenpaths is 260, ' so we use a slightly smaller number to have some Error margin. ' ' Source: https://docs.microsoft.com/de-de/windows/win32/fileio/naming-a-file?redirectedfrom=MSDN#maximum-path-length-limitation Private Const MAX_FILE_PATH_LENGTH = 250 ' This prevents an infinite loop when no file can be created in a location Private Const MAX_FILE_VERSION = 100 Private Const VERSION_SEPARATOR As Char = "~"c Private Const FILE_NAME_ACCESS_TEST = "accessTest.txt" Public Sub New(LogConfig As LogConfig) _LogConfig = LogConfig _Logger = LogConfig.GetLogger() _invalidFilenameChars = String.Join("", Path.GetInvalidFileNameChars()) _invalidPathChars = String.Join("", Path.GetInvalidPathChars()) End Sub Public Function GetCleanFilename(FileName As String) As String _Logger.Debug("Filename before cleaning: [{0}]", FileName) Dim oCleanName As String = FileName oCleanName = Regex.Replace(oCleanName, _invalidFilenameChars, String.Empty) oCleanName = Regex.Replace(oCleanName, REGEX_CLEAN_FILENAME, String.Empty, RegexOptions.Singleline) oCleanName = Regex.Replace(oCleanName, "\s{2,}", " ") oCleanName = Regex.Replace(oCleanName, "\.{2,}", ".") _Logger.Debug("Filename after cleaning: [{0}]", oCleanName) Return oCleanName End Function Public Function GetCleanPath(FilePath As String) As String _Logger.Debug("Path before cleaning: [{0}]", FilePath) Dim oCleanName As String = FilePath oCleanName = Regex.Replace(oCleanName, _invalidPathChars, String.Empty) oCleanName = Regex.Replace(oCleanName, REGEX_CLEAN_PATH, String.Empty, RegexOptions.Singleline) _Logger.Debug("Path after cleaning: [{0}]", oCleanName) Return oCleanName End Function ''' ''' Reads the file at `FilePath` and computes a SHA256 Hash from its contents ''' ''' ''' Public Function GetChecksum(FilePath As String) As String Try Using oFileStream = IO.File.OpenRead(FilePath) Using oStream As New BufferedStream(oFileStream, 1200000) Dim oChecksum() As Byte = SHA256.Create.ComputeHash(oStream) Return FormatHash(oChecksum) End Using End Using Catch ex As Exception _Logger.Error(ex) Return Nothing End Try End Function Public Function GetChecksumFromString(pStringToCheck As String) As String Dim oBytes() As Byte = Encoding.UTF8.GetBytes(pStringToCheck) Dim oChecksum() As Byte = SHA256.Create.ComputeHash(oBytes) Return FormatHash(oChecksum) End Function Public Function GetHash(FilePath As String) As String Return GetChecksum(FilePath) End Function Public Function GetHashFromString(pStringToCheck As String) As String Return GetChecksumFromString(pStringToCheck) End Function Private Function FormatHash(pChecksum) Return BitConverter. ToString(pChecksum). Replace("-", String.Empty) End Function ''' ''' Adds file version string to given filename `Destination` if that file already exists. ''' ''' Filepath to check ''' Versioned string Public Function GetVersionedFilename(pFilePath As String) As String Return GetVersionedFilenameWithFilecheck(pFilePath, Function(pPath As String) IO.File.Exists(pPath)) End Function ''' ''' Adds file version string to given filename `Destination` if that file already exists. ''' ''' Filepath to check ''' Custom action to check for file existence ''' Versioned string Public Function GetVersionedFilenameWithFilecheck(pFilePath As String, pFileExistsAction As Func(Of String, Boolean)) As String Try Dim oFileName As String = pFilePath Dim oFinalFileName = oFileName Dim oDestinationDir = Path.GetDirectoryName(oFileName) Dim oExtension = Path.GetExtension(oFileName) Dim oFileNameWithoutExtension = Path.GetFileNameWithoutExtension(oFileName) Dim oSplitResult = GetVersionedString(oFileNameWithoutExtension) oFileNameWithoutExtension = oSplitResult.Item1 Dim oFileVersion As Integer = oSplitResult.Item2 ' Shorten the filename (only filename, without extension or version) ' by cutting the length in half. This should work no matter how long the path and/or filename are. ' The initial check operates on the full path to catch all scenarios. If pFilePath.Length > MAX_FILE_PATH_LENGTH Then _Logger.Info("Filename is too long. Filename will be cut to prevent further errors.") _Logger.Info("Original Filename is: {0}", oFileNameWithoutExtension) Dim oNewLength As Integer = CInt(Math.Floor(oFileNameWithoutExtension.Length / 2.0)) Dim oNewFileNameWithoutExtension = oFileNameWithoutExtension.Substring(0, oNewLength) _Logger.Info("New Filename will be: {0}", oNewFileNameWithoutExtension) oFileNameWithoutExtension = oNewFileNameWithoutExtension End If ' while file exists, increment version. ' version cannot go above MAX_FILE_VERSION, to prevent infinite loop Do oFinalFileName = Path.Combine(oDestinationDir, GetFilenameWithVersion(oFileNameWithoutExtension, oFileVersion, oExtension)) _Logger.Debug("Intermediate Filename is {0}", oFinalFileName) _Logger.Debug("File version: {0}", oFileVersion) oFileVersion += 1 Loop While pFileExistsAction(oFinalFileName) = True And oFileVersion < MAX_FILE_VERSION If oFileVersion >= MAX_FILE_VERSION Then Throw New OverflowException($"Tried '{MAX_FILE_VERSION}' times to version filename before giving up. Sorry.") End If _Logger.Debug("Final Filename is {0}", oFinalFileName) Return oFinalFileName Catch ex As Exception _Logger.Warn("Filename {0} could not be versioned. Original filename will be returned!", pFilePath) _Logger.Error(ex) Return pFilePath End Try End Function ''' ''' Split String at version separator to: ''' check if string is already versioned, ''' get the string version of an already versioned string ''' ''' ''' Examples: ''' test1.pdf --> test1 --> ['test1'] --> no fileversion ''' test1~2.pdf --> test1~2 --> ['test1', '2'] --> version 2 ''' test1~12345~2.pdf --> test1~12345~2 --> ['test1', '12345', '2'] --> still version 2 ''' somestring~3 --> somestring~3 --> ['somestring', '3'] --> version 3 ''' ''' The string to versioned ''' The character to split at ''' Tuple of string and version Public Function GetVersionedString(pString As String) As Tuple(Of String, Integer) Dim oSplitString = pString.Split(VERSION_SEPARATOR).ToList() Dim oStringVersion As Integer ' if string is already versioned, extract string version ' else just use the string and set version to 1 If oSplitString.Count > 1 Then Dim oVersion As Integer = 1 Try oVersion = Integer.Parse(oSplitString.Last()) pString = String.Join("", oSplitString.Take(oSplitString.Count - 1)) Catch ex As Exception ' pString does NOT change pString = pString Finally oStringVersion = oVersion End Try Else oStringVersion = 1 End If _Logger.Debug("Versioned: String [{0}], Version [{1}]", pString, oStringVersion) Return New Tuple(Of String, Integer)(pString, oStringVersion) End Function Public Function GetAppDataPath(CompanyName As String, ProductName As String) Dim oLocalAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) Return Path.Combine(oLocalAppData, CompanyName, ProductName) End Function Private Function GetFilenameWithVersion(FileNameWithoutExtension As String, FileVersion As Integer, Extension As String) As String If FileVersion <= 1 Then Return $"{FileNameWithoutExtension}{Extension}" Else Return $"{FileNameWithoutExtension}{VERSION_SEPARATOR}{FileVersion}{Extension}" End If End Function ''' ''' Removes files in a directory filtered by filename, extension and last write date ''' ''' The directory in which files will be deleted ''' Only delete files which are older than x days. Must be between 0 and 1000 days. ''' A filename filter which will be checked ''' A file extension which will be checked ''' Should the function continue with deleting when a file could not be deleted? ''' True if all files were deleted or if no files were deleted, otherwise false Public Function RemoveFiles(Path As String, FileKeepTime As Integer, FileBaseName As String, Optional FileExtension As String = "log", Optional ContinueOnError As Boolean = True) As Boolean If Not TestPathIsDirectory(Path) Then Throw New ArgumentException($"Path {Path} is not a directory!") End If If Not Directory.Exists(Path) Then Throw New DirectoryNotFoundException($"Path {Path} does not exist!") End If If FileKeepTime < 0 Or FileKeepTime > 1000 Then Throw New ArgumentOutOfRangeException("FileKeepTime must be an integer between 0 and 1000!") End If Dim oUnableToDeleteCounter = 0 Dim oDirectory As New DirectoryInfo(Path) Dim oDateLimit As DateTime = DateTime.Now.AddDays(FileKeepTime) Dim oFiles As List(Of FileInfo) = oDirectory. EnumerateFiles($"*{FileBaseName}*"). Where(Function(oFileInfo As FileInfo) Return oFileInfo.Extension = FileExtension And oFileInfo.LastWriteTime < oDateLimit End Function). ToList() If oFiles.Count = 0 Then _Logger.Debug("No files found that match the criterias.") Return True End If _Logger.Debug("Deleting old files (Found {0}).", oFiles.Count) For Each oFile As FileInfo In oFiles Try oFile.Delete() Catch ex As Exception If ContinueOnError = False Then _Logger.Warn("Deleting files was aborted at file {0}.", oFile.FullName) Return False End If oUnableToDeleteCounter = oUnableToDeleteCounter + 1 _Logger.Warn("File {0} could not be deleted!") End Try Next If oUnableToDeleteCounter > 0 Then _Logger.Debug("Old files partially removed. {0} files could not be removed.", oUnableToDeleteCounter) Else _Logger.Debug("Old files removed.") End If Return True End Function Public Sub MoveTo(FilePath As String, Directory As String) Dim oFileInfo As New FileInfo(FilePath) IO.File.Move(FilePath, Path.Combine(Directory, oFileInfo.Name)) End Sub Public Sub MoveTo(FilePath As String, NewFileName As String, Directory As String) IO.File.Move(FilePath, Path.Combine(Directory, NewFileName)) End Sub ''' ''' Copied from https://docs.microsoft.com/en-us/dotnet/standard/io/how-to-copy-directories ''' ''' ''' ''' Public Sub CopyDirectory(ByVal SourceDirName As String, ByVal DestDirName As String, ByVal CopySubDirs As Boolean) Dim oDirectory As DirectoryInfo = New DirectoryInfo(SourceDirName) If Not oDirectory.Exists Then Throw New DirectoryNotFoundException("Source directory does not exist or could not be found: " & SourceDirName) End If Dim oDirectories As DirectoryInfo() = oDirectory.GetDirectories() Directory.CreateDirectory(DestDirName) Dim oFiles As FileInfo() = oDirectory.GetFiles() For Each oFile As FileInfo In oFiles Dim tempPath As String = Path.Combine(DestDirName, oFile.Name) oFile.CopyTo(tempPath, False) Next If CopySubDirs Then For Each oSubDirectory As DirectoryInfo In oDirectories Dim oTempPath As String = Path.Combine(DestDirName, oSubDirectory.Name) CopyDirectory(oSubDirectory.FullName, oTempPath, CopySubDirs) Next End If End Sub ''' ''' Tries to create a directory and returns its path. ''' Returns a temp path if `DirectoryPath` can not be created or written to. ''' ''' The directory to create ''' Should a write access test be performed? ''' The used path Public Function CreateDirectory(DirectoryPath As String, Optional TestWriteAccess As Boolean = True) As String Dim oFinalPath As String If Directory.Exists(DirectoryPath) Then _Logger.Debug("Directory {0} already exists. Skipping.", DirectoryPath) oFinalPath = DirectoryPath Else Try Directory.CreateDirectory(DirectoryPath) oFinalPath = DirectoryPath Catch ex As Exception _Logger.Error(ex) _Logger.Warn("Directory {0} could not be created. Temp path will be used instead.", DirectoryPath) oFinalPath = Path.GetTempPath() End Try End If If TestWriteAccess AndAlso Not TestPathIsWritable(DirectoryPath) Then _Logger.Warn("Directory {0} is not writable. Temp path will be used instead.", DirectoryPath) oFinalPath = Path.GetTempPath() Else oFinalPath = DirectoryPath End If _Logger.Debug("Using path {0}", oFinalPath) Return oFinalPath End Function Public Function TestPathIsWritable(DirectoryPath As String) As Boolean Try Dim fileAccessPath = Path.Combine(DirectoryPath, FILE_NAME_ACCESS_TEST) Using fs As FileStream = IO.File.Create(fileAccessPath) fs.WriteByte(0) End Using IO.File.Delete(fileAccessPath) Return True Catch ex As Exception Return False End Try End Function ''' ''' Checks if a file is locked, ie. in use by another process. ''' ''' ''' https://docs.microsoft.com/en-us/dotnet/standard/io/handling-io-errors ''' https://stackoverflow.com/questions/876473/is-there-a-way-to-check-if-a-file-is-in-use ''' Public Function TestFileIsLocked(pFilePath As String) As Boolean Try Using stream As FileStream = IO.File.Open(pFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None) stream.Close() End Using Catch ex As Exception When ((ex.HResult And &HFFFF) = 32) Return True Catch ex As Exception Return True End Try Return False End Function Public Function TestPathIsDirectory(Path As String) As Boolean If Not Directory.Exists(Path) Then Return False End If Dim oIsDirectory As Boolean = (System.IO.File.GetAttributes(Path) And FileAttributes.Directory) = FileAttributes.Directory Return oIsDirectory End Function ''' ''' Checks the size of the supplied file. ''' ''' ''' ''' Public Function TestFileSizeIsLessThanMaxFileSize(pFilePath As String, pMaxFileSizeInMegabytes As Integer) As Boolean Dim oFileInfo As New FileInfo(pFilePath) _Logger.Info("Checking Filesize of {0}", oFileInfo.Name) _Logger.Debug("Filesize threshold is {0} MB.", pMaxFileSizeInMegabytes) If pMaxFileSizeInMegabytes <= 0 Then _Logger.Debug("Filesize is not configured. Skipping check.") Return True End If Dim oMaxSize = pMaxFileSizeInMegabytes * 1024 * 1024 If oMaxSize > 0 And oFileInfo.Length > oMaxSize Then _Logger.Debug("Filesize is bigger than threshold.") Return False Else _Logger.Debug("Filesize is smaller than threshold. All fine.") Return True End If End Function Public Function GetDateDirectory(pBaseDirectory As String, pDate As Date) As String Dim oDateDirectory = GetDateString(pDate) Dim oFinalDirectory As String = IO.Path.Combine(pBaseDirectory, oDateDirectory) Return oFinalDirectory End Function Public Function GetDateDirectory(pBaseDirectory As String) As String Return GetDateDirectory(pBaseDirectory, Now) End Function Public Function CreateDateDirectory(pBaseDirectory As String, pDate As Date) As String Dim oDateDirectory = GetDateString(pDate) Dim oFinalDirectory As String = IO.Path.Combine(pBaseDirectory, oDateDirectory) If IO.Directory.Exists(oFinalDirectory) = False Then _Logger.Debug("Path does not exist, creating: [{0}]", oFinalDirectory) Try Directory.CreateDirectory(oFinalDirectory) _Logger.Debug("Created folder [{0}]", oFinalDirectory) Catch ex As Exception _Logger.Warn("Final path [{0}] could not be created!", oFinalDirectory) _Logger.Error(ex) End Try End If Return oFinalDirectory End Function Public Function CreateDateDirectory(pBaseDirectory As String) As String Return CreateDateDirectory(pBaseDirectory, Now) End Function Public Function GetDateString() As String Return $"{Now:yyyy\\MM\\dd}" End Function Public Function GetDateString(pDate As Date) As String Return $"{pDate:yyyy\\MM\\dd}" End Function Public Function GetDateTimeString() As String Return $"{Now:yyyy-MM-dd_hh-mm-ffff}" End Function Public Function GetDateTimeString(pDate As Date) As String Return $"{pDate:yyyy-MM-dd_hh-mm-ffff}" End Function Public Function GetFilenameWithSuffix(pFilePath As String, pSuffix As String) Dim oFileInfo = New IO.FileInfo(pFilePath) Return GetFilenameWithSuffix(IO.Path.GetFileNameWithoutExtension(pFilePath), pSuffix, oFileInfo.Extension.Substring(1)) End Function Public Function GetFilenameWithSuffix(pBaseString As String, pSuffix As String, pExtension As String) Return $"{pBaseString}-{pSuffix}.{pExtension}" End Function Public Function GetFilenameWithPrefix(pFilePath As String, pPrefix As String) Dim oFileInfo = New IO.FileInfo(pFilePath) Return GetFilenameWithSuffix(IO.Path.GetFileNameWithoutExtension(pFilePath), pPrefix, oFileInfo.Extension.Substring(1)) End Function Public Function GetFilenameWithPrefix(pBaseString As String, pPrefix As String, pExtension As String) Return $"{pPrefix}-{pBaseString}.{pExtension}" End Function End Class