diff --git a/Shoko.Server/API/v3/Controllers/FileController.cs b/Shoko.Server/API/v3/Controllers/FileController.cs index 5016d5e0e..303bb54d5 100644 --- a/Shoko.Server/API/v3/Controllers/FileController.cs +++ b/Shoko.Server/API/v3/Controllers/FileController.cs @@ -1354,12 +1354,14 @@ private static void CheckXRefsForFile(SVR_VideoLocal file, ModelStateDictionary /// Set to true to include series and episode cross-references. /// Include data from selected s. /// Limit the number of returned results. + /// Set to true to skip the per directory comparison logic and + /// instead rely on the global platform comparison based on the currently running platform. /// A list of all files with a file location that ends with the given path. [HttpGet("PathEndsWith")] public ActionResult> PathEndsWithQuery([FromQuery] string path, [FromQuery] bool includeXRefs = true, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, - [Range(0, 100)] int limit = 0) - => PathEndsWithInternal(path, includeXRefs, includeDataFrom, limit); + [Range(0, 100)] int limit = 0, [FromQuery] bool quickCompare = false) + => PathEndsWithInternal(path, includeXRefs, includeDataFrom, limit, quickCompare); /// /// Search for a file by path or name. Internally, it will convert forward @@ -1370,12 +1372,14 @@ public ActionResult> PathEndsWithQuery([FromQuery] string path, [From /// Set to true to include series and episode cross-references. /// Include data from selected s. /// Limit the number of returned results. + /// Set to true to skip the per directory comparison logic and + /// instead rely on the global platform comparison based on the currently running platform. /// A list of all files with a file location that ends with the given path. [HttpGet("PathEndsWith/{*path}")] public ActionResult> PathEndsWithPath([FromRoute] string path, [FromQuery] bool includeXRefs = true, [FromQuery, ModelBinder(typeof(CommaDelimitedModelBinder))] HashSet includeDataFrom = null, - [Range(0, 100)] int limit = 0) - => PathEndsWithInternal(Uri.UnescapeDataString(path), includeXRefs, includeDataFrom, limit); + [Range(0, 100)] int limit = 0, [FromQuery] bool quickCompare = true) + => PathEndsWithInternal(Uri.UnescapeDataString(path), includeXRefs, includeDataFrom, limit, quickCompare); /// /// Search for a file by path or name. Internally, it will convert forward @@ -1386,10 +1390,12 @@ public ActionResult> PathEndsWithPath([FromRoute] string path, [FromQ /// Set to true to include series and episode cross-references. /// Include data from selected s. /// Limit the number of returned results. + /// Set to true to skip the per directory comparison logic and + /// instead rely on the global platform comparison based on the currently running platform. /// A list of all files with a file location that ends with the given path. [NonAction] private ActionResult> PathEndsWithInternal(string path, bool includeXRefs, - HashSet includeDataFrom, int limit = 0) + HashSet includeDataFrom, int limit = 0, bool quickCompare = true) { if (string.IsNullOrWhiteSpace(path)) return new List(); @@ -1399,7 +1405,15 @@ private ActionResult> PathEndsWithInternal(string path, bool includeX .Replace('\\', Path.DirectorySeparatorChar); var results = RepoFactory.VideoLocalPlace.GetAll() .AsParallel() - .Where(location => location.FullServerPath?.EndsWith(query, StringComparison.OrdinalIgnoreCase) ?? false) + .Where(location => + { + var serverPath = location.FullServerPath; + if (string.IsNullOrEmpty(serverPath)) + return false; + + var comparison = quickCompare ? Utils.PlatformComparison : Utils.GetComparisonFor(Path.GetDirectoryName(serverPath)); + return serverPath.EndsWith(query, comparison); + }) .Select(location => location.VideoLocal) .Where(file => { diff --git a/Shoko.Server/API/v3/Controllers/ImportFolderController.cs b/Shoko.Server/API/v3/Controllers/ImportFolderController.cs index 92b0ac21b..613407b5f 100644 --- a/Shoko.Server/API/v3/Controllers/ImportFolderController.cs +++ b/Shoko.Server/API/v3/Controllers/ImportFolderController.cs @@ -12,6 +12,7 @@ using Shoko.Server.Repositories; using Shoko.Server.Services; using Shoko.Server.Settings; +using Shoko.Server.Utilities; namespace Shoko.Server.API.v3.Controllers; @@ -53,7 +54,17 @@ public ActionResult AddImportFolder([FromBody] ImportFolder folder if (!Directory.Exists(folder.Path)) return ValidationProblem("Path does not exist. Import Folders must be a location that exists on the server.", nameof(folder.Path)); - if (RepoFactory.ImportFolder.GetAll().ExceptBy([folder.ID], iF => iF.ImportFolderID).Any(iF => folder.Path.StartsWith(iF.ImportFolderLocation, StringComparison.OrdinalIgnoreCase) || iF.ImportFolderLocation.StartsWith(folder.Path, StringComparison.OrdinalIgnoreCase))) + if (RepoFactory.ImportFolder.GetAll().ExceptBy([folder.ID], iF => iF.ImportFolderID).Any(iF => + { + var comparison = Utils.GetComparisonFor(folder.Path, iF.ImportFolderLocation); + var newLocation = folder.Path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (newLocation[^1] != Path.DirectorySeparatorChar) + newLocation += Path.DirectorySeparatorChar; + var existingLocation = iF.ImportFolderLocation.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (existingLocation[^1] != Path.DirectorySeparatorChar) + existingLocation += Path.DirectorySeparatorChar; + return newLocation.StartsWith(existingLocation, comparison) || existingLocation.StartsWith(newLocation, comparison); + })) return ValidationProblem("Unable to nest an import folder within another import folder."); try @@ -137,8 +148,17 @@ public ActionResult EditImportFolder([FromBody] ImportFolder folder) if (!string.IsNullOrEmpty(folder.Path) && !Directory.Exists(folder.Path)) ModelState.AddModelError(nameof(folder.Path), "Path does not exist. Import Folders must be a location that exists on the server."); - if (RepoFactory.ImportFolder.GetAll().ExceptBy([folder.ID], iF => iF.ImportFolderID) - .Any(iF => folder.Path.StartsWith(iF.ImportFolderLocation, StringComparison.OrdinalIgnoreCase) || iF.ImportFolderLocation.StartsWith(folder.Path, StringComparison.OrdinalIgnoreCase))) + if (RepoFactory.ImportFolder.GetAll().ExceptBy([folder.ID], iF => iF.ImportFolderID).Any(iF => + { + var comparison = Utils.GetComparisonFor(folder.Path, iF.ImportFolderLocation); + var newLocation = folder.Path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (newLocation[^1] != Path.DirectorySeparatorChar) + newLocation += Path.DirectorySeparatorChar; + var existingLocation = iF.ImportFolderLocation.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (existingLocation[^1] != Path.DirectorySeparatorChar) + existingLocation += Path.DirectorySeparatorChar; + return newLocation.StartsWith(existingLocation, comparison) || existingLocation.StartsWith(newLocation, comparison); + })) ModelState.AddModelError(nameof(folder.Path), "Unable to nest an import folder within another import folder."); if (folder.ID == 0) diff --git a/Shoko.Server/API/v3/Controllers/RenamerController.cs b/Shoko.Server/API/v3/Controllers/RenamerController.cs index 1df473ab9..5f4b4a6ef 100644 --- a/Shoko.Server/API/v3/Controllers/RenamerController.cs +++ b/Shoko.Server/API/v3/Controllers/RenamerController.cs @@ -588,7 +588,8 @@ public async Task> DirectlyRelocateFileLocation([ // Sanitize relative path and reject paths leading to outside the import folder. var fullPath = Path.GetFullPath(Path.Combine(importFolder.ImportFolderLocation, body.RelativePath)); - if (!fullPath.StartsWith(importFolder.ImportFolderLocation, StringComparison.OrdinalIgnoreCase)) + // We use ordinal here since we want to compare the exact path with what we got from the import folder to what the final path ends up being. + if (!fullPath.StartsWith(importFolder.ImportFolderLocation, StringComparison.Ordinal)) return BadRequest("The provided relative path leads outside the import folder."); var sanitizedRelativePath = Path.GetRelativePath(importFolder.ImportFolderLocation, fullPath); diff --git a/Shoko.Server/API/v3/Controllers/SeriesController.cs b/Shoko.Server/API/v3/Controllers/SeriesController.cs index c1770e230..c1a090fc7 100644 --- a/Shoko.Server/API/v3/Controllers/SeriesController.cs +++ b/Shoko.Server/API/v3/Controllers/SeriesController.cs @@ -2821,11 +2821,14 @@ public ActionResult MoveSeries([FromRoute, Range(1, int.MaxValue)] int seriesID, } /// - /// Get the series that reside in the path that ends with + /// Get the series that reside in the path that ends with . /// + /// The path to search for. + /// Set to true to skip the per directory comparison logic and + /// instead rely on the global platform comparison based on the currently running platform. /// [HttpGet("PathEndsWith/{*path}")] - public ActionResult> PathEndsWith([FromRoute] string path) + public ActionResult> PathEndsWith([FromRoute] string path, bool quickCompare = true) { var user = User; var query = path; @@ -2845,9 +2848,14 @@ public ActionResult> PathEndsWith([FromRoute] string path) return RepoFactory.VideoLocalPlace.GetAll() .Where(a => { - if (a.FullServerPath == null) return false; + if (a.FullServerPath == null) + return false; var dir = Path.GetDirectoryName(a.FullServerPath); - return dir != null && dir.EndsWith(query, StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(dir)) + return false; + + var comparison = quickCompare ? Utils.PlatformComparison : Utils.GetComparisonFor(dir); + return dir != null && dir.EndsWith(query, comparison); }) .SelectMany(a => a.VideoLocal?.AnimeEpisodes ?? Enumerable.Empty()) .DistinctBy(a => a.AnimeSeriesID) diff --git a/Shoko.Server/FileHelper/Hasher.cs b/Shoko.Server/FileHelper/Hasher.cs index 1073b5ee4..18286d6ad 100644 --- a/Shoko.Server/FileHelper/Hasher.cs +++ b/Shoko.Server/FileHelper/Hasher.cs @@ -140,7 +140,7 @@ public static string GetVersion() public static Hashes CalculateHashes(string strPath, OnHashProgress onHashProgress, bool getCRC32, bool getMD5, bool getSHA1) { var rhash = new Hashes(); - if (Finalise.ModuleHandle == IntPtr.Zero && !Utils.IsLinux) + if (Finalise.ModuleHandle == IntPtr.Zero && !Utils.IsLinuxOrMac) return CalculateHashes_here(strPath, onHashProgress, getCRC32, getMD5, getSHA1); var hash = new byte[56]; @@ -149,7 +149,7 @@ public static Hashes CalculateHashes(string strPath, OnHashProgress onHashProgre try { var filename = strPath; - if (!Utils.IsLinux) + if (!Utils.IsLinuxOrMac) { filename = strPath.StartsWith(@"\\") ? strPath diff --git a/Shoko.Server/FileHelper/MD4Managed.cs b/Shoko.Server/FileHelper/MD4Managed.cs index 52b152973..fd2e4efb3 100644 --- a/Shoko.Server/FileHelper/MD4Managed.cs +++ b/Shoko.Server/FileHelper/MD4Managed.cs @@ -22,7 +22,7 @@ protected MD4() { var obj = CryptoConfig.CreateFromName(hashName); // in case machine.config isn't configured to use any MD4 implementation - if (obj == null || Utils.IsRunningOnLinuxOrMac()) + if (obj == null || Utils.IsLinuxOrMac) { obj = new MD4Managed(); } diff --git a/Shoko.Server/Renamer/RenameFileService.cs b/Shoko.Server/Renamer/RenameFileService.cs index 522ce4486..3127132a7 100644 --- a/Shoko.Server/Renamer/RenameFileService.cs +++ b/Shoko.Server/Renamer/RenameFileService.cs @@ -240,14 +240,15 @@ private static RelocationResult UnAbstractResult(SVR_VideoLocal_Place place, Abs var newRelativeDirectory = shouldMove && !result.SkipMove ? result.Path : Path.GetDirectoryName(place.FilePath); var newRelativePath = !string.IsNullOrEmpty(newRelativeDirectory) && newRelativeDirectory.Length > 0 ? Path.Combine(newRelativeDirectory, newFileName) : newFileName; var newFullPath = Path.Combine(newImportFolder.Path, newRelativePath); + var oldFullPath = place.FullServerPath; + var comparison = Utils.GetComparisonFor(Path.GetDirectoryName(newFullPath), Path.GetDirectoryName(oldFullPath)); return new() { Success = true, ImportFolder = newImportFolder, RelativePath = newRelativePath, - // TODO: Handle file-systems that are or aren't case sensitive. - Renamed = !string.Equals(place.FileName, result.FileName, StringComparison.OrdinalIgnoreCase), - Moved = !string.Equals(Path.GetDirectoryName(place.FullServerPath), Path.GetDirectoryName(newFullPath), StringComparison.OrdinalIgnoreCase), + Renamed = !string.Equals(place.FileName, result.FileName, comparison), + Moved = !string.Equals(Path.GetDirectoryName(oldFullPath), Path.GetDirectoryName(newFullPath), comparison), }; } diff --git a/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs b/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs index e97848135..bfcb60be0 100644 --- a/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs +++ b/Shoko.Server/Repositories/Cached/ImportFolderRepository.cs @@ -4,6 +4,7 @@ using Shoko.Models.Server; using Shoko.Server.Databases; using Shoko.Server.Models; +using Shoko.Server.Utilities; namespace Shoko.Server.Repositories.Cached; @@ -73,10 +74,19 @@ public SVR_ImportFolder SaveImportFolder(ImportFolder folder) throw new Exception("Cannot find Import Folder location"); } - if (GetAll().ExceptBy([folder.ImportFolderID], iF => iF.ImportFolderID).Any(iF => folder.ImportFolderLocation.StartsWith(iF.ImportFolderLocation, StringComparison.OrdinalIgnoreCase) || iF.ImportFolderLocation.StartsWith(folder.ImportFolderLocation, StringComparison.OrdinalIgnoreCase))) + if (GetAll().ExceptBy([folder.ImportFolderID], iF => iF.ImportFolderID).Any(iF => + { + var comparison = Utils.GetComparisonFor(folder.ImportFolderLocation, iF.ImportFolderLocation); + var newLocation = folder.ImportFolderLocation.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (newLocation[^1] != Path.DirectorySeparatorChar) + newLocation += Path.DirectorySeparatorChar; + var existingLocation = iF.ImportFolderLocation.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + if (existingLocation[^1] != Path.DirectorySeparatorChar) + existingLocation += Path.DirectorySeparatorChar; + return newLocation.StartsWith(existingLocation, comparison) || existingLocation.StartsWith(newLocation, comparison); + })) throw new Exception("Unable to nest an import folder within another import folder."); - ns.ImportFolderName = folder.ImportFolderName; ns.ImportFolderLocation = folder.ImportFolderLocation; ns.IsDropDestination = folder.IsDropDestination; diff --git a/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs b/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs index 708b9b207..c219ae57b 100644 --- a/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs +++ b/Shoko.Server/Scheduling/Jobs/Shoko/HashFileJob.cs @@ -277,7 +277,7 @@ private bool HandleReadOnlyException(Exception ex) var info = new FileInfo(FilePath); if (info.IsReadOnly) info.IsReadOnly = false; - if (!info.IsReadOnly && !Utils.IsRunningOnLinuxOrMac()) + if (!info.IsReadOnly && !Utils.IsLinuxOrMac) { return true; } diff --git a/Shoko.Server/Server/ShokoServer.cs b/Shoko.Server/Server/ShokoServer.cs index a68aca68d..06c976de2 100644 --- a/Shoko.Server/Server/ShokoServer.cs +++ b/Shoko.Server/Server/ShokoServer.cs @@ -117,7 +117,7 @@ public bool StartUpServer() private bool CheckBlockedFiles() { - if (Utils.IsRunningOnLinuxOrMac()) return true; + if (Utils.IsLinuxOrMac) return true; var programlocation = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); diff --git a/Shoko.Server/Services/ActionService.cs b/Shoko.Server/Services/ActionService.cs index c71bfb1bf..409c779a3 100644 --- a/Shoko.Server/Services/ActionService.cs +++ b/Shoko.Server/Services/ActionService.cs @@ -162,12 +162,14 @@ public async Task RunImport_ScanFolder(int importFolderID, bool skipMyList = fal dictFilesExisting[vl.FullServerPath] = vl; } - Utils.GetFilesForImportFolder(folder.BaseDirectory, ref fileList); + var baseDirectory = folder.BaseDirectory; + var comparer = Utils.GetComparerFor(baseDirectory.FullName); + Utils.GetFilesForImportFolder(baseDirectory, ref fileList); // Get Ignored Files and remove them from the scan listing var ignoredFiles = RepoFactory.VideoLocal.GetIgnoredVideos().SelectMany(a => a.Places) .Select(a => a.FullServerPath).Where(a => !string.IsNullOrEmpty(a)).ToList(); - fileList = fileList.Except(ignoredFiles, StringComparer.InvariantCultureIgnoreCase).ToList(); + fileList = fileList.Except(ignoredFiles, comparer).ToList(); // get a list of all files in the share foreach (var fileName in fileList) @@ -210,19 +212,27 @@ public async Task RunImport_DropFolders() { var settings = _settingsProvider.GetSettings(); var scheduler = await _schedulerFactory.GetScheduler(); + // Get Ignored Files and remove them from the scan listing + var ignoredFiles = RepoFactory.VideoLocal.GetIgnoredVideos() + .SelectMany(a => a.Places) + .Select(a => a.FullServerPath) + .Where(a => !string.IsNullOrEmpty(a)) + .ToList(); + // get a complete list of files var fileList = new List(); - foreach (var share in RepoFactory.ImportFolder.GetAll()) + foreach (var folder in RepoFactory.ImportFolder.GetAll()) { - if (!share.FolderIsDropSource) continue; - Utils.GetFilesForImportFolder(share.BaseDirectory, ref fileList); + if (!folder.FolderIsDropSource) continue; + + var fileListForFolder = new List(); + var baseDirectory = folder.BaseDirectory; + var comparer = Utils.GetComparerFor(baseDirectory.FullName); + Utils.GetFilesForImportFolder(baseDirectory, ref fileListForFolder); + fileListForFolder = fileListForFolder.Except(ignoredFiles, comparer).ToList(); + fileList.AddRange(fileListForFolder); } - // Get Ignored Files and remove them from the scan listing - var ignoredFiles = RepoFactory.VideoLocal.GetIgnoredVideos().SelectMany(a => a.Places) - .Select(a => a.FullServerPath).Where(a => !string.IsNullOrEmpty(a)).ToList(); - fileList = fileList.Except(ignoredFiles, StringComparer.InvariantCultureIgnoreCase).ToList(); - // get a list of all the shares we are looking at int filesFound = 0, videosFound = 0; var i = 0; diff --git a/Shoko.Server/Services/VideoLocal_PlaceService.cs b/Shoko.Server/Services/VideoLocal_PlaceService.cs index bd174b59e..51c80ec5e 100644 --- a/Shoko.Server/Services/VideoLocal_PlaceService.cs +++ b/Shoko.Server/Services/VideoLocal_PlaceService.cs @@ -106,7 +106,8 @@ public async Task DirectlyRelocateFile(SVR_VideoLocal_Place pl // Sanitize relative path and reject paths leading to outside the import folder. var fullPath = Path.GetFullPath(Path.Combine(request.ImportFolder.Path, request.RelativePath)); - if (!fullPath.StartsWith(request.ImportFolder.Path, StringComparison.OrdinalIgnoreCase)) + // We use ordinal here since we want to compare the exact path with what we got from the import folder to what the final path ends up being. + if (!fullPath.StartsWith(request.ImportFolder.Path, StringComparison.Ordinal)) return new() { Success = false, @@ -193,11 +194,12 @@ public async Task DirectlyRelocateFile(SVR_VideoLocal_Place pl var newFolderPath = Path.GetDirectoryName(newRelativePath); var newFullPath = Path.Combine(request.ImportFolder.Path, newRelativePath); var newFileName = Path.GetFileName(newRelativePath); - var renamed = !string.Equals(Path.GetFileName(oldRelativePath), newFileName, StringComparison.OrdinalIgnoreCase); - var moved = !string.Equals(Path.GetDirectoryName(oldFullPath), Path.GetDirectoryName(newFullPath), StringComparison.OrdinalIgnoreCase); + var comparison = Utils.GetComparisonFor(Path.GetDirectoryName(newFullPath), Path.GetDirectoryName(oldFullPath)); + var renamed = !string.Equals(Path.GetFileName(oldRelativePath), newFileName, comparison); + var moved = !string.Equals(Path.GetDirectoryName(oldFullPath), Path.GetDirectoryName(newFullPath), comparison); // Last ditch effort to ensure we aren't moving a file unto itself - if (string.Equals(newFullPath, oldFullPath, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(newFullPath, oldFullPath, comparison)) { _logger.LogTrace("Resolved to relocate {FilePath} onto itself. Nothing to do.", newFullPath); return new() @@ -612,6 +614,7 @@ private void MoveExternalSubtitles(string newFullServerPath, string oldFullServe string.IsNullOrEmpty(oldFileName) || string.IsNullOrEmpty(newFileName)) return; + var comparison = Utils.GetComparisonFor(Path.GetDirectoryName(newFullServerPath), Path.GetDirectoryName(oldFullServerPath)); var textStreams = SubtitleHelper.GetSubtitleStreams(oldFullServerPath); // move any subtitle files foreach (var subtitleFile in textStreams) @@ -628,7 +631,7 @@ private void MoveExternalSubtitles(string newFullServerPath, string oldFullServe } var newSubPath = Path.Combine(newParent, newFileName + subtitleFile.Filename[oldFileName.Length..]); - if (string.Equals(subPath, newSubPath, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(subPath, newSubPath, comparison)) { _logger.LogDebug("Attempting to move subtitle file onto itself. Skipping. Path: {FilePath} to {FilePath}", subPath, newSubPath); continue; diff --git a/Shoko.Server/Settings/Migration/ServerSettings_Legacy.cs b/Shoko.Server/Settings/Migration/ServerSettings_Legacy.cs index 1181734bf..c8382f240 100644 --- a/Shoko.Server/Settings/Migration/ServerSettings_Legacy.cs +++ b/Shoko.Server/Settings/Migration/ServerSettings_Legacy.cs @@ -25,7 +25,7 @@ private static string ApplicationPath { get { - if (Utils.IsLinux) + if (Utils.IsLinuxOrMac) { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".shoko", diff --git a/Shoko.Server/Utilities/AVDumpHelper.cs b/Shoko.Server/Utilities/AVDumpHelper.cs index b35fcde18..a683e1fa3 100644 --- a/Shoko.Server/Utilities/AVDumpHelper.cs +++ b/Shoko.Server/Utilities/AVDumpHelper.cs @@ -31,7 +31,7 @@ public static partial class AVDumpHelper private const string AVDumpURL = @"AVD3_URL_GOES_HERE"; - private static readonly string AVDumpExecutable = Path.Combine(WorkingDirectory, Utils.IsRunningOnLinuxOrMac() ? "AVDump3CL.dll" : "AVDump3CL.exe"); + private static readonly string AVDumpExecutable = Path.Combine(WorkingDirectory, Utils.IsLinuxOrMac ? "AVDump3CL.dll" : "AVDump3CL.exe"); private static readonly ConcurrentDictionary ActiveSessions = new(); @@ -479,7 +479,7 @@ private static Process GetSubProcessForOS(params string[] argumentList) CreateNoWindow = true }; - if (Utils.IsRunningOnLinuxOrMac()) + if (Utils.IsLinuxOrMac) { startInfo.FileName = "dotnet"; startInfo.ArgumentList.Add(AVDumpExecutable); diff --git a/Shoko.Server/Utilities/FileSystemWatcher/RecoveringFileSystemWatcher.cs b/Shoko.Server/Utilities/FileSystemWatcher/RecoveringFileSystemWatcher.cs index 8abc1ce1a..b26a17db1 100644 --- a/Shoko.Server/Utilities/FileSystemWatcher/RecoveringFileSystemWatcher.cs +++ b/Shoko.Server/Utilities/FileSystemWatcher/RecoveringFileSystemWatcher.cs @@ -386,7 +386,7 @@ private long CanAccessFile(string fileName, ref Exception e) if (info.IsReadOnly) info.IsReadOnly = false; // check to see if it stuck. On linux, we can't just WinAPI hack our way out, so don't recurse in that case, anyway - if (!new FileInfo(fileName).IsReadOnly && !Utils.IsRunningOnLinuxOrMac()) return GetFileSize(fileName, accessType); + if (!new FileInfo(fileName).IsReadOnly && !Utils.IsLinuxOrMac) return GetFileSize(fileName, accessType); } catch { diff --git a/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs b/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs index 6251ff280..48fb8ec58 100644 --- a/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs +++ b/Shoko.Server/Utilities/MediaInfoLib/MediaInfo.cs @@ -132,7 +132,7 @@ private static string GetMediaInfoPathForOS() path = settings.Import.MediaInfoPath; if (!string.IsNullOrEmpty(path) && File.Exists(path)) return path; - if (Utils.IsRunningOnLinuxOrMac()) return "mediainfo"; + if (Utils.IsLinuxOrMac) return "mediainfo"; var exePath = Assembly.GetEntryAssembly()?.Location; var exeDir = Path.GetDirectoryName(exePath); diff --git a/Shoko.Server/Utilities/Utils.cs b/Shoko.Server/Utilities/Utils.cs index 6550011b5..0601ef5a8 100644 --- a/Shoko.Server/Utilities/Utils.cs +++ b/Shoko.Server/Utilities/Utils.cs @@ -19,8 +19,8 @@ using Quartz.Logging; using Shoko.Models.Enums; using Shoko.Server.API.SignalR.NLog; -using Shoko.Server.Providers.AniDB.Titles; using Shoko.Server.Server; +using Shoko.Server.Services; using Shoko.Server.Settings; namespace Shoko.Server.Utilities; @@ -46,7 +46,7 @@ public static string ApplicationPath if (!string.IsNullOrWhiteSpace(shokoHome)) return _applicationPath = Path.GetFullPath(shokoHome); - if (IsLinux) + if (IsLinuxOrMac) return _applicationPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".shoko", DefaultInstance); @@ -133,7 +133,7 @@ public static void InitLogger() } LogProvider.SetLogProvider(new NLog.Extensions.Logging.NLogLoggerFactory()); - + LogManager.ReconfigExistingLoggers(); } @@ -414,20 +414,125 @@ public static bool IsDirectoryWritable(string dirPath, bool throwIfFails = false } } - public static bool IsLinux + public static bool IsLinuxOrMac + => (int)Environment.OSVersion.Platform is 4 or 6 or 128; + + public static StringComparison PlatformComparison { get; private set; } = IsLinuxOrMac + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + /// + /// Returns the correct for the given file system directories. This is useful for string operations that need to be case-sensitive or insensitive depending on the underlying file systems. + /// + /// The paths to the directories to check. + /// The correct for the given file system directories. + public static StringComparison GetComparisonFor(params string[] directoryPaths) => + directoryPaths.Any(directoryPath => IsFileSystemCaseSensitive(directoryPath) ?? IsLinuxOrMac) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + /// + /// Returns the correct for the given file system directories. This is useful for string operations that need to be case-sensitive or insensitive depending on the underlying file systems. + /// + /// The paths to the directories to check. + /// The correct for the given file system directories. + public static StringComparer GetComparerFor(params string[] directoryPaths) => + directoryPaths.Any(directoryPath => IsFileSystemCaseSensitive(directoryPath) ?? IsLinuxOrMac) ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; + + // https://referencesource.microsoft.com/mscorlib/microsoft/win32/win32native.cs.html#9f6ca3226ff8f9ba + // https://referencesource.microsoft.com/mscorlib/microsoft/win32/win32native.cs.html#dd35d7f626262141 + private const int ERROR_FILE_EXISTS = unchecked((int)0x80070050); + + /// + /// Check whether the operating system handles file names case-sensitive in + /// the specified directory or the the first existing parent directory of + /// the specified directory. + /// + /// + /// Modified from; + /// https://stackoverflow.com/questions/430256/how-do-i-determine-whether-the-filesystem-is-case-sensitive-in-net/53228078#53228078 + /// + /// The path to the directory to check. + /// A value indicating whether the operating system handles file names case-sensitive in the specified directory. + /// is null. + /// contains one or more invalid characters. + /// The specified directory does not exist. + /// The current user has no write permission to the specified directory. + private static bool? IsFileSystemCaseSensitive(string directoryPath) { - get + try { - var p = (int)Environment.OSVersion.Platform; - return p == 4 || p == 6 || p == 128; - } - } + while (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath)) + directoryPath = Path.GetDirectoryName(directoryPath); - public static bool IsRunningOnLinuxOrMac() - { - return !RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - } + if (string.IsNullOrEmpty(directoryPath)) + return null; + var fileWatcherService = Utils.ServiceContainer.GetRequiredService(); + while (true) + { + var fileNameLower = ".cstest." + Guid.NewGuid().ToString(); + var fileNameUpper = fileNameLower.ToUpperInvariant(); + var filePathLower = Path.Combine(directoryPath, fileNameLower); + var filePathUpper = Path.Combine(directoryPath, fileNameUpper); + FileStream fileStreamLower = null; + FileStream fileStreamUpper = null; + try + { + fileWatcherService.AddFileWatcherExclusion(filePathLower); + fileWatcherService.AddFileWatcherExclusion(filePathUpper); + try + { + // Try to create filePathUpper to ensure a unique non-existing file. + fileStreamUpper = new FileStream(filePathUpper, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 4096, FileOptions.DeleteOnClose); + + // After ensuring that it didn't exist before, filePathUpper must be closed/deleted again to ensure correct opening of filePathLower, regardless of the case-sensitivity of the file system. + // On case-sensitive file systems there is a tiny chance for a race condition, where another process could create filePathUpper between closing/deleting it here and newly creating it after filePathLower. + // This method would then incorrectly indicate a case-insensitive file system. + fileStreamUpper.Dispose(); + } + catch (IOException ioException) when (ioException.HResult is ERROR_FILE_EXISTS) + { + // filePathUpper already exists, try another file name + continue; + } + + try + { + fileStreamLower = new FileStream(filePathLower, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 4096, FileOptions.DeleteOnClose); + } + catch (IOException ioException) when (ioException.HResult is ERROR_FILE_EXISTS) + { + // filePathLower already exists, try another file name + continue; + } + + try + { + fileStreamUpper = new FileStream(filePathUpper, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 4096, FileOptions.DeleteOnClose); + + // filePathUpper does not exist, this indicates case-sensitivity + return true; + } + catch (IOException ioException) when (ioException.HResult is ERROR_FILE_EXISTS) + { + // fileNameUpper already exists, this indicates case-insensitivity + return false; + } + } + finally + { + fileStreamLower?.Dispose(); + fileStreamUpper?.Dispose(); + fileWatcherService.RemoveFileWatcherExclusion(filePathLower); + fileWatcherService.RemoveFileWatcherExclusion(filePathUpper); + } + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to determine file system case-sensitivity for directory {directoryPath}"); + return null; + } + } /// /// Determines an encoded string's encoding by analyzing its byte order mark (BOM). /// Defaults to ASCII when detection of the text file's endianness fails.