Skip to content

Commit 215cd05

Browse files
CopilotLeftofZen
andauthored
fix: apply second review feedback
Agent-Logs-Url: https://github.com/OpenLoco/ObjectEditor/sessions/4e0b3e6d-0b3f-4ddf-a12e-36820d17d4dd Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com>
1 parent dde257a commit 215cd05

5 files changed

Lines changed: 71 additions & 21 deletions

File tree

Gui/Models/FileSystemItems.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ public bool CanDownload
3838
public bool CanOpenFolder
3939
=> FileLocation == FileLocationKind.Local && IsLeafNode && !string.IsNullOrEmpty(FileName);
4040

41+
[JsonIgnore]
42+
public bool HasContextActions
43+
=> CanOpenFolder || CanDownload;
44+
4145
public uint? DatChecksum { get; init; }
4246

4347
public ulong? xxHash3 { get; init; }

Gui/ViewModels/FolderTreeViewModel.cs

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,45 @@ static FileSystemItem CreateOnlineScenarioFileSystemItem(DtoScenarioEntry item)
779779
OnlineApiEndpointGroup = OnlineApiEndpointGroup.Scenarios,
780780
};
781781

782+
static string SanitizeDownloadName(string? name, bool stripDirectoryComponents, bool stripExtension, string fallbackName)
783+
{
784+
var sanitizedName = name ?? string.Empty;
785+
if (stripDirectoryComponents)
786+
{
787+
sanitizedName = sanitizedName.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty;
788+
}
789+
790+
if (stripExtension)
791+
{
792+
sanitizedName = Path.GetFileNameWithoutExtension(sanitizedName);
793+
}
794+
795+
sanitizedName = Path.GetInvalidFileNameChars().Aggregate(sanitizedName, (current, c) => current.Replace(c, '_'));
796+
sanitizedName = sanitizedName
797+
.Replace('/', '_')
798+
.Replace('\\', '_')
799+
.Replace("..", "__", StringComparison.Ordinal);
800+
801+
return string.IsNullOrWhiteSpace(sanitizedName) ? fallbackName : sanitizedName;
802+
}
803+
804+
async Task ShowDownloadFailureDialogAsync(string contentMessage)
805+
{
806+
var box = MessageBoxManager.GetMessageBoxStandard(new MessageBoxStandardParams
807+
{
808+
ContentTitle = "Download failed",
809+
ContentMessage = contentMessage,
810+
ButtonDefinitions = ButtonEnum.Ok,
811+
Icon = Icon.Warning,
812+
WindowStartupLocation = WindowStartupLocation.CenterOwner,
813+
Topmost = true,
814+
ShowInCenter = true,
815+
SizeToContent = SizeToContent.WidthAndHeight,
816+
MinHeight = 170,
817+
});
818+
_ = await box.ShowAsync();
819+
}
820+
782821
async Task DownloadOnlineItemAsync(FileSystemItem item)
783822
{
784823
if (EditorContext.ObjectServiceClient == null || item.Id == null)
@@ -800,10 +839,24 @@ async Task DownloadOnlineItemAsync(FileSystemItem item)
800839
}
801840

802841
var extension = item.OnlineApiEndpointGroup == OnlineApiEndpointGroup.Scenarios ? ".SC5" : ".dat";
803-
var safeName = Path.GetInvalidFileNameChars().Aggregate(item.DisplayName, (current, c) => current.Replace(c, '_'));
842+
var safeName = SanitizeDownloadName(item.DisplayName, stripDirectoryComponents: true, stripExtension: true, fallbackName: "download");
804843
var filename = Path.Combine(EditorContext.Settings.DownloadFolder, $"{safeName}-{item.Id}{extension}");
805-
await File.WriteAllBytesAsync(filename, fileBytes);
806-
EditorContext.Logger.Info($"Downloaded \"{item.DisplayName}\" to \"{filename}\"");
844+
try
845+
{
846+
await using var outputStream = new FileStream(filename, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true);
847+
await outputStream.WriteAsync(fileBytes);
848+
EditorContext.Logger.Info($"Downloaded \"{item.DisplayName}\" to \"{filename}\"");
849+
}
850+
catch (IOException ex)
851+
{
852+
EditorContext.Logger.Error($"Failed to download \"{item.DisplayName}\" to \"{filename}\"", ex);
853+
await ShowDownloadFailureDialogAsync($"Could not create:\n{filename}\n\nThe destination file may already exist, be locked by another process, or the download folder may be unavailable.");
854+
}
855+
catch (UnauthorizedAccessException ex)
856+
{
857+
EditorContext.Logger.Error($"Failed to download \"{item.DisplayName}\" to \"{filename}\" due to insufficient permissions", ex);
858+
await ShowDownloadFailureDialogAsync($"You do not have permission to write to:\n{filename}\n\nPlease check folder permissions or choose a different download folder.");
859+
}
807860
}
808861

809862
async Task DownloadOnlinePackAsync(OnlineItemPackBrowseResult pack)
@@ -826,7 +879,7 @@ async Task DownloadOnlinePackAsync(OnlineItemPackBrowseResult pack)
826879
return;
827880
}
828881

829-
var safePackName = Path.GetInvalidFileNameChars().Aggregate(pack.Name, (current, c) => current.Replace(c, '_'));
882+
var safePackName = SanitizeDownloadName(pack.Name, stripDirectoryComponents: false, stripExtension: false, fallbackName: "pack");
830883
var filename = Path.Combine(EditorContext.Settings.DownloadFolder, $"{safePackName}-{pack.Id}.zip");
831884
try
832885
{
@@ -837,19 +890,7 @@ async Task DownloadOnlinePackAsync(OnlineItemPackBrowseResult pack)
837890
catch (IOException ex)
838891
{
839892
EditorContext.Logger.Error($"Failed to download pack \"{pack.Name}\" to \"{filename}\"", ex);
840-
var box = MessageBoxManager.GetMessageBoxStandard(new MessageBoxStandardParams
841-
{
842-
ContentTitle = "Download failed",
843-
ContentMessage = $"Could not create:\n{filename}\n\nA file may already exist or be locked by another process.",
844-
ButtonDefinitions = ButtonEnum.Ok,
845-
Icon = Icon.Warning,
846-
WindowStartupLocation = WindowStartupLocation.CenterOwner,
847-
Topmost = true,
848-
ShowInCenter = true,
849-
SizeToContent = SizeToContent.WidthAndHeight,
850-
MinHeight = 170,
851-
});
852-
_ = await box.ShowAsync();
893+
await ShowDownloadFailureDialogAsync($"Could not create:\n{filename}\n\nThe destination file may already exist, be locked by another process, or the download folder may be unavailable.");
853894
}
854895
}
855896
}

Gui/Views/FolderTreeView.axaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@
158158
<materialIcons:MaterialIcon Kind="{Binding DisplayName, Converter={StaticResource EnumToMaterialIconConverter}}" Width="24" Height="24" Margin="2"/>
159159
<TextBlock VerticalAlignment="Center" Text="{Binding NameComputed}"/>
160160
<StackPanel.ContextMenu>
161-
<ContextMenu>
161+
<ContextMenu IsVisible="{Binding HasContextActions}" IsEnabled="{Binding HasContextActions}">
162162
<MenuItem Header="Open folder" IsVisible="{Binding CanOpenFolder}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).OpenFolderFor}" CommandParameter="{Binding}" />
163163
<MenuItem Header="Download" IsVisible="{Binding CanDownload}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).DownloadOnlineItemCommand}" CommandParameter="{Binding}" />
164164
</ContextMenu>
@@ -171,7 +171,7 @@
171171
<materialIcons:MaterialIcon Kind="{Binding DisplayName, Converter={StaticResource EnumToMaterialIconConverter}}" Width="24" Height="24" Margin="2" />
172172
<TextBlock VerticalAlignment="Center" Text="{Binding NameComputed}"/>
173173
<StackPanel.ContextMenu>
174-
<ContextMenu>
174+
<ContextMenu IsVisible="{Binding HasContextActions}" IsEnabled="{Binding HasContextActions}">
175175
<MenuItem Header="Open folder" IsVisible="{Binding CanOpenFolder}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).OpenFolderFor}" CommandParameter="{Binding}" />
176176
<MenuItem Header="Download" IsVisible="{Binding CanDownload}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).DownloadOnlineItemCommand}" CommandParameter="{Binding}" />
177177
</ContextMenu>

ObjectService/RouteHandlers/RouteHelpers.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ namespace ObjectService.RouteHandlers;
22

33
public static class RouteHelpers
44
{
5-
static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
5+
static readonly char[] PathSeparators = ['/', '\\'];
66

77
public static string MakeNicePlural(string name)
88
=> $"{name.Replace("RouteHandler", string.Empty)}s";
@@ -37,7 +37,7 @@ public static bool TryGetSafeRelativePathUnderRoot(string rootPath, string? rela
3737
}
3838

3939
fullPath = candidateFullPath;
40-
normalizedRelativePath = relativePath.Replace('\\', '/');
40+
normalizedRelativePath = string.Join('/', segments);
4141
return true;
4242
}
4343
}

Tests/ObjectServiceIntegrationTests/DownloadRoutesTest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public async Task GetSC5FilePackFileAsync_ReturnsZipWithOnlySafeScenarioEntries(
7575

7676
var outsidePath = Path.Combine(rootFolder, "outside.SC5");
7777
await File.WriteAllBytesAsync(outsidePath, [7, 7, 7]);
78+
await File.WriteAllBytesAsync(Path.Combine(sfm.ScenariosFolder, @"..\outside-windows.SC5"), [6, 6, 6]);
7879

7980
var pack = new TblSC5FilePack
8081
{
@@ -84,6 +85,7 @@ public async Task GetSC5FilePackFileAsync_ReturnsZipWithOnlySafeScenarioEntries(
8485
[
8586
new TblSC5File { Id = 1, Name = safeRelativePath },
8687
new TblSC5File { Id = 2, Name = Path.Combine("..", "outside.SC5") },
88+
new TblSC5File { Id = 3, Name = @"..\outside-windows.SC5" },
8789
],
8890
};
8991

@@ -123,9 +125,11 @@ public async Task GetObjectPackFileAsync_ReturnsZipWithOnlySafeIndexedObjectEntr
123125

124126
var outsidePath = Path.Combine(rootFolder, "outside.dat");
125127
await File.WriteAllBytesAsync(outsidePath, [8, 8, 8]);
128+
await File.WriteAllBytesAsync(Path.Combine(sfm.ObjectsFolder, @"..\outside-windows.dat"), [5, 5, 5]);
126129

127130
sfm.ObjectIndex.Objects.Add(new ObjectIndexEntry("SAFEOBJ", safeRelativePath, null, 111, null, ObjectType.Vehicle, ObjectSource.Custom, null, null));
128131
sfm.ObjectIndex.Objects.Add(new ObjectIndexEntry("UNSAFEOBJ", Path.Combine("..", "outside.dat"), null, 222, null, ObjectType.Vehicle, ObjectSource.Custom, null, null));
132+
sfm.ObjectIndex.Objects.Add(new ObjectIndexEntry("WINDOWSOBJ", @"..\outside-windows.dat", null, 333, null, ObjectType.Vehicle, ObjectSource.Custom, null, null));
129133

130134
var obj = new TblObject
131135
{
@@ -139,6 +143,7 @@ public async Task GetObjectPackFileAsync_ReturnsZipWithOnlySafeIndexedObjectEntr
139143
[
140144
new TblDatObject { Id = 1, DatName = "SAFEOBJ", DatChecksum = 111, xxHash3 = 1, ObjectId = 1 },
141145
new TblDatObject { Id = 2, DatName = "UNSAFEOBJ", DatChecksum = 222, xxHash3 = 2, ObjectId = 1 },
146+
new TblDatObject { Id = 3, DatName = "WINDOWSOBJ", DatChecksum = 333, xxHash3 = 3, ObjectId = 1 },
142147
],
143148
};
144149

0 commit comments

Comments
 (0)