Fix app path resolution for server, add better tests
This commit is contained in:
parent
3eae04abdd
commit
2ca1db535c
10 changed files with 243 additions and 54 deletions
102
LANCommander.SDK.Tests/AppPathsTests.cs
Normal file
102
LANCommander.SDK.Tests/AppPathsTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => AppPaths.ResolveStorageLocationPath(path!));
|
||||
}
|
||||
|
||||
// ── GetConfigDirectory ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetConfigDirectory_ReturnsAbsoluteExistingDirectory()
|
||||
{
|
||||
var configDir = AppPaths.GetConfigDirectory();
|
||||
|
||||
Assert.True(Path.IsPathRooted(configDir));
|
||||
Assert.True(Directory.Exists(configDir));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no override, the data root is a "Data" folder under the current working directory when writable.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a full path under the application's config directory.
|
||||
/// </summary>
|
||||
|
|
@ -17,9 +21,33 @@ public static class AppPaths
|
|||
public static string GetConfigPath(params string[] paths)
|
||||
=> Path.Combine(GetConfigDirectory(), Path.Combine(paths));
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="storageLocationPath">The configured storage location path (absolute or relative).</param>
|
||||
/// <param name="segments">Additional path segments appended to the resolved storage location.</param>
|
||||
/// <returns>The absolute path to the storage location (plus any appended segments).</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="DataDirectoryEnvironmentVariable"/> 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.
|
||||
/// </summary>
|
||||
/// <returns>The resolved config directory path.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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:
|
||||
/// <c>%LOCALAPPDATA%</c> on Windows, <c>~/Library/Application Support</c> on macOS, and
|
||||
/// <c>$XDG_DATA_HOME</c> (<c>~/.local/share</c>) on Linux.
|
||||
/// </summary>
|
||||
/// <returns>The local application data path for this application.</returns>
|
||||
/// <returns>The application data path for this application.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> 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<string> GetArchiveFileLocationAsync(string objectKey)
|
||||
|
|
|
|||
|
|
@ -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<StorageLocation> GetDefaultStorageLocationAsync()
|
||||
|
|
|
|||
|
|
@ -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<string> GetThumbnailPathAsync(Guid id)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -96,6 +96,66 @@ public class SaveClientTests(ApplicationFixture fixture) : BaseTest(fixture)
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="GameSaveService.GetSavePath"/>,
|
||||
/// which must anchor a relative storage location beneath the config directory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSavePath_RelativeStorageLocation_ResolvesUnderConfigDirectory()
|
||||
{
|
||||
var saveService = GetService<GameSaveService>();
|
||||
|
||||
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<GameSaveService>();
|
||||
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue