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());
- }
- }
-}