diff --git a/LANCommander.SDK.Tests/AppPathsTests.cs b/LANCommander.SDK.Tests/AppPathsTests.cs new file mode 100644 index 00000000..e7671c54 --- /dev/null +++ b/LANCommander.SDK.Tests/AppPathsTests.cs @@ -0,0 +1,102 @@ +using LANCommander.SDK.Helpers; + +namespace LANCommander.SDK.Tests; + +public class AppPathsTests +{ + // ── ResolveStorageLocationPath: rooted paths ───────────────────────────── + + [Fact] + public void ResolveStorageLocationPath_RootedPath_ReturnedAsIs() + { + var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + var resolved = AppPaths.ResolveStorageLocationPath(rooted); + + Assert.Equal(rooted, resolved); + } + + [Fact] + public void ResolveStorageLocationPath_RootedPathWithSegments_CombinesUnderRoot() + { + var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + var resolved = AppPaths.ResolveStorageLocationPath(rooted, "user", "game", "save"); + + Assert.Equal(Path.Combine(rooted, "user", "game", "save"), resolved); + } + + // ── ResolveStorageLocationPath: relative paths anchor to the config dir ─── + + [Fact] + public void ResolveStorageLocationPath_RelativePath_AnchoredToConfigDirectory() + { + var resolved = AppPaths.ResolveStorageLocationPath("Saves"); + + Assert.Equal(Path.Combine(AppPaths.GetConfigDirectory(), "Saves"), resolved); + } + + [Fact] + public void ResolveStorageLocationPath_RelativePathWithSegments_AnchoredToConfigDirectory() + { + var resolved = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save"); + + Assert.Equal( + Path.Combine(AppPaths.GetConfigDirectory(), "Saves", "user", "game", "save"), + resolved); + } + + /// + /// Regression guard for the reported bug: writes and reads of the same save both went through two + /// different resolvers that disagreed for relative storage paths (one anchored to the working + /// directory, the other to the config directory). Every consumer must now resolve identically. + /// + [Fact] + public void ResolveStorageLocationPath_SameRelativeInput_IsDeterministicAcrossCallers() + { + var writer = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save"); + var reader = AppPaths.ResolveStorageLocationPath("Saves", "user", "game", "save"); + + Assert.Equal(writer, reader); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveStorageLocationPath_NullOrWhitespacePath_Throws(string? path) + { + Assert.Throws(() => AppPaths.ResolveStorageLocationPath(path!)); + } + + // ── GetConfigDirectory ─────────────────────────────────────────────────── + + [Fact] + public void GetConfigDirectory_ReturnsAbsoluteExistingDirectory() + { + var configDir = AppPaths.GetConfigDirectory(); + + Assert.True(Path.IsPathRooted(configDir)); + Assert.True(Directory.Exists(configDir)); + } + + /// + /// With no override, the data root is a "Data" folder under the current working directory when writable. + /// + [Fact] + public void GetConfigDirectory_AnchoredToWorkingDirectory() + { + // Skip when an operator override or a read-only working directory changes the anchor. + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(AppPaths.DataDirectoryEnvironmentVariable))) + return; + + var workingDir = Directory.GetCurrentDirectory(); + + if (!DirectoryHelper.IsDirectoryWritable(workingDir)) + return; + + var configDir = Path.GetFullPath(AppPaths.GetConfigDirectory()); + + Assert.Equal(Path.GetFullPath(Path.Combine(workingDir, "Data")), configDir); + } +} diff --git a/LANCommander.SDK/AppPaths.cs b/LANCommander.SDK/AppPaths.cs index 9989440d..381901c3 100644 --- a/LANCommander.SDK/AppPaths.cs +++ b/LANCommander.SDK/AppPaths.cs @@ -1,6 +1,8 @@ using System; using System.IO; +using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using LANCommander.SDK.Helpers; namespace LANCommander.SDK; @@ -9,6 +11,8 @@ public static class AppPaths { private static string _configDirectory = String.Empty; + public const string DataDirectoryEnvironmentVariable = "LANCOMMANDER_DATA_DIR"; + /// /// Builds a full path under the application's config directory. /// @@ -17,9 +21,33 @@ public static class AppPaths public static string GetConfigPath(params string[] paths) => Path.Combine(GetConfigDirectory(), Path.Combine(paths)); + /// + /// Resolves a storage location path to an absolute path using a single, consistent rule so that + /// every consumer (saves, media, archives, ...) resolves the same way: rooted paths are used as-is, + /// while relative paths are resolved beneath the config directory (i.e. next to the server binary). + /// + /// The configured storage location path (absolute or relative). + /// Additional path segments appended to the resolved storage location. + /// The absolute path to the storage location (plus any appended segments). + public static string ResolveStorageLocationPath(string storageLocationPath, params string[] segments) + { + if (String.IsNullOrWhiteSpace(storageLocationPath)) + throw new ArgumentException("A storage location path must be provided.", nameof(storageLocationPath)); + + var root = Path.IsPathRooted(storageLocationPath) + ? storageLocationPath + : Path.Combine(GetConfigDirectory(), storageLocationPath); + + return segments is { Length: > 0 } + ? Path.Combine(new[] { root }.Concat(segments).ToArray()) + : root; + } + /// /// Locates (and creates if necessary) the directory in which application data will be stored. - /// Prefers the current working directory when writable; otherwise falls back to the user's local application data. + /// Resolution order: the override if set; otherwise a + /// "Data" folder under the current working directory when writable; otherwise a "Data" folder under + /// the current user's platform-native application data directory. /// /// The resolved config directory path. public static string GetConfigDirectory() @@ -27,36 +55,57 @@ public static class AppPaths if (!String.IsNullOrWhiteSpace(_configDirectory)) return _configDirectory; - var baseDirectory = Directory.GetCurrentDirectory(); + var overrideDirectory = Environment.GetEnvironmentVariable(DataDirectoryEnvironmentVariable); - if (DirectoryHelper.IsDirectoryWritable(baseDirectory)) - _configDirectory = baseDirectory; + if (!String.IsNullOrWhiteSpace(overrideDirectory)) + { + // Operator-specified data root is used verbatim (no implicit "Data" subfolder). + _configDirectory = Path.GetFullPath(overrideDirectory); + } else - _configDirectory = GetAppDataPath(); - - _configDirectory = Path.Combine(_configDirectory, "Data"); - + { + var baseDirectory = Directory.GetCurrentDirectory(); + + _configDirectory = DirectoryHelper.IsDirectoryWritable(baseDirectory) + ? Path.Combine(baseDirectory, "Data") + : Path.Combine(GetAppDataPath(), "Data"); + } + if (!Directory.Exists(_configDirectory)) Directory.CreateDirectory(_configDirectory); - + return _configDirectory; } /// - /// Gets (and creates if necessary) the base local application data directory for the current user, - /// scoped by the entry assembly's company and product metadata. + /// Gets (and creates if necessary) the base per-user application data directory for the current user, + /// scoped by the entry assembly's company and product metadata. Uses the platform-native convention: + /// %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, and + /// $XDG_DATA_HOME (~/.local/share) on Linux. /// - /// The local application data path for this application. + /// The application data path for this application. public static string GetAppDataPath() { var (company, product) = GetCompanyAndProduct(); - var userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - - var appDataPath = Path.Combine(userRoot, company, product); - + + string userRoot; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + // .NET maps LocalApplicationData to ~/.local/share on macOS; use the native location instead. + userRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support"); + else + // Windows: %LOCALAPPDATA%. Linux: $XDG_DATA_HOME or ~/.local/share. + userRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + var appDataPath = Path.Combine(new[] { userRoot, company, product } + .Where(segment => !String.IsNullOrWhiteSpace(segment)) + .ToArray()!); + if (!Directory.Exists(appDataPath)) Directory.CreateDirectory(appDataPath); - + return appDataPath; } diff --git a/LANCommander.Server.Services/ArchiveService.cs b/LANCommander.Server.Services/ArchiveService.cs index 55cd352c..19dc0b24 100644 --- a/LANCommander.Server.Services/ArchiveService.cs +++ b/LANCommander.Server.Services/ArchiveService.cs @@ -36,27 +36,23 @@ namespace LANCommander.Server.Services public string GetArchiveFileLocation(Archive archive, StorageLocation storageLocation) { - return Path.IsPathRooted(storageLocation.Path) - ? Path.Combine(storageLocation.Path, archive.ObjectKey) - : AppPaths.GetConfigPath(storageLocation.Path, archive.ObjectKey); + return AppPaths.ResolveStorageLocationPath(storageLocation.Path, archive.ObjectKey); } public async Task GetArchiveFileLocationAsync(Archive archive) { string storageLocationPath; - + if (archive.StorageLocation != null) storageLocationPath = archive.StorageLocation.Path; else { var storageLocation = await storageLocationService.GetAsync(archive.StorageLocationId); - + storageLocationPath = storageLocation.Path; } - - return Path.IsPathRooted(storageLocationPath) ? - Path.Combine(storageLocationPath, archive.ObjectKey) : - AppPaths.GetConfigPath(storageLocationPath, archive.ObjectKey); + + return AppPaths.ResolveStorageLocationPath(storageLocationPath, archive.ObjectKey); } public async Task GetArchiveFileLocationAsync(string objectKey) diff --git a/LANCommander.Server.Services/GameSaveService.cs b/LANCommander.Server.Services/GameSaveService.cs index d558f550..1d5b35e9 100644 --- a/LANCommander.Server.Services/GameSaveService.cs +++ b/LANCommander.Server.Services/GameSaveService.cs @@ -71,9 +71,7 @@ namespace LANCommander.Server.Services public string GetSavePath(GameSave save) { var gameId = save.GameId ?? throw new InvalidOperationException($"No game ID is available for save {save.Id}"); - return Path.IsPathRooted(save.StorageLocation.Path) ? - Path.Combine(save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), $"{save.Id}") : - Path.Combine(AppPaths.GetConfigDirectory(), save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), $"{save.Id}"); + return AppPaths.ResolveStorageLocationPath(save.StorageLocation.Path, save.UserId.ToString(), gameId.ToString(), save.Id.ToString()); } public async Task GetDefaultStorageLocationAsync() diff --git a/LANCommander.Server.Services/MediaService.cs b/LANCommander.Server.Services/MediaService.cs index 239b06b2..7ea548cc 100644 --- a/LANCommander.Server.Services/MediaService.cs +++ b/LANCommander.Server.Services/MediaService.cs @@ -97,9 +97,7 @@ namespace LANCommander.Server.Services GetMediaPath(entity.FileId, entity.StorageLocation); public static string GetMediaPath(Guid id, StorageLocation storageLocation) => - Path.IsPathRooted(storageLocation.Path) - ? Path.Combine(storageLocation.Path, id.ToString()) - : Path.Combine(AppPaths.GetConfigDirectory(), storageLocation.Path, id.ToString()); + AppPaths.ResolveStorageLocationPath(storageLocation.Path, id.ToString()); public async Task GetThumbnailPathAsync(Guid id) { diff --git a/LANCommander.Server.Services/UpdateService.cs b/LANCommander.Server.Services/UpdateService.cs index f4cdc7ec..d756e8c9 100644 --- a/LANCommander.Server.Services/UpdateService.cs +++ b/LANCommander.Server.Services/UpdateService.cs @@ -256,9 +256,7 @@ namespace LANCommander.Server.Services GetLauncherFileLocation(artifact.Name); public string GetLauncherFileLocation(string objectKey) => - Path.IsPathRooted(_settingsProvider.CurrentValue.Server.Launcher.StoragePath) ? - Path.Combine(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey) : - Path.Combine(AppPaths.GetConfigDirectory(), _settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey); + AppPaths.ResolveStorageLocationPath(_settingsProvider.CurrentValue.Server.Launcher.StoragePath, objectKey); public LauncherArtifact GetLauncherArtifact(string objectKey) { diff --git a/LANCommander.Server.Tests/Services/SaveClientTests.cs b/LANCommander.Server.Tests/Services/SaveClientTests.cs index 1b1a9760..cd79ff96 100644 --- a/LANCommander.Server.Tests/Services/SaveClientTests.cs +++ b/LANCommander.Server.Tests/Services/SaveClientTests.cs @@ -96,6 +96,66 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture) } } + /// + /// Regression for the reported bug: saves were written to a working-directory-relative location while + /// downloads looked under the config directory. Upload and download now share , + /// which must anchor a relative storage location beneath the config directory. + /// + [Fact] + public void GetSavePath_RelativeStorageLocation_ResolvesUnderConfigDirectory() + { + var saveService = GetService(); + + var save = new GameSave + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + GameId = Guid.NewGuid(), + StorageLocation = new StorageLocation + { + Path = "Saves", + Type = StorageLocationType.Save, + }, + }; + + var path = saveService.GetSavePath(save); + + path.ShouldBe(Path.Combine( + AppPaths.GetConfigDirectory(), + "Saves", + save.UserId.ToString(), + save.GameId.ToString(), + save.Id.ToString())); + } + + [Fact] + public void GetSavePath_RootedStorageLocation_UsedVerbatim() + { + var saveService = GetService(); + + var rooted = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + + var save = new GameSave + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + GameId = Guid.NewGuid(), + StorageLocation = new StorageLocation + { + Path = rooted, + Type = StorageLocationType.Save, + }, + }; + + var path = saveService.GetSavePath(save); + + path.ShouldBe(Path.Combine( + rooted, + save.UserId.ToString(), + save.GameId.ToString(), + save.Id.ToString())); + } + [Fact] public async Task SaveUploadWorksAsync() { diff --git a/LANCommander.Server/Endpoints/DownloadEndpoints.cs b/LANCommander.Server/Endpoints/DownloadEndpoints.cs index aadb440e..9ae8199d 100644 --- a/LANCommander.Server/Endpoints/DownloadEndpoints.cs +++ b/LANCommander.Server/Endpoints/DownloadEndpoints.cs @@ -80,6 +80,7 @@ public static class DownloadEndpoints var save = await gameSaveService .Include(s => s.Game!) .Include(s => s.User!) + .Include(s => s.StorageLocation!) .GetAsync(id); if (user == null || user.Identity?.Name != save.User?.UserName && !user.IsInRole(RoleService.AdministratorRoleName)) diff --git a/LANCommander.Server/Endpoints/SaveEndpoints.cs b/LANCommander.Server/Endpoints/SaveEndpoints.cs index f5976060..56f960dd 100644 --- a/LANCommander.Server/Endpoints/SaveEndpoints.cs +++ b/LANCommander.Server/Endpoints/SaveEndpoints.cs @@ -170,8 +170,8 @@ public static class SaveEndpoints if (latestSave == null) return TypedResults.NotFound(); - var fileName = latestSave.GetUploadPath(); - + var fileName = saveService.GetSavePath(latestSave); + if (!File.Exists(fileName)) return TypedResults.NotFound(); @@ -201,11 +201,11 @@ public static class SaveEndpoints .Include(s => s.StorageLocation) .FirstOrDefaultAsync(s => s.Id == id && s.UserId == user.Id); - var fileName = save.GetUploadPath(); - + var fileName = saveService.GetSavePath(save); + if (!File.Exists(fileName)) return TypedResults.NotFound(); - + var downloadName = $"{save.Game.Title} - {user.UserName} - {save.CreatedOn}".SanitizeFilename(); return TypedResults.File(new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read), "application/octet-stream", $"{downloadName}.lcs"); @@ -297,7 +297,7 @@ public static class SaveEndpoints try { - var saveUploadFile = save.GetUploadPath(); + var saveUploadFile = saveService.GetSavePath(save); var saveUploadPath = Path.GetDirectoryName(saveUploadFile); if (!Directory.Exists(saveUploadPath)) diff --git a/LANCommander.Server/Extensions/GameSaveExtensions.cs b/LANCommander.Server/Extensions/GameSaveExtensions.cs deleted file mode 100644 index 16079209..00000000 --- a/LANCommander.Server/Extensions/GameSaveExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using LANCommander.Server.Services; -using Steamworks.Data; - -namespace LANCommander.Server.Extensions -{ - public static class GameSaveExtensions - { - public static string GetUploadPath(this Data.Models.GameSave gameSave) - { - return Path.Combine(gameSave.StorageLocation.Path, gameSave.UserId.ToString(), gameSave.GameId.ToString(), gameSave.Id.ToString()); - } - } -}