From 2765d3eef88d9727f65f67376f4fab11efed83aa Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 11 Oct 2026 01:17:26 +0100 Subject: [PATCH] Fix URL-safe encoding of uploaded filenames Replace the fragment marker used for unsupported filename characters with an underscore and cover upload metadata and static-file downloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FileSystem/AbstractFileSystemHelpers.cs | 3 +- .../Yavsc.Blogs.Tests/FileSystemApiTests.cs | 17 +++-- .../Yavsc.Blogs.Tests/FilterFileNameTests.cs | 76 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs diff --git a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs index 7665df8ab..46eedb571 100644 --- a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs +++ b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs @@ -49,6 +49,7 @@ namespace Yavsc.Server.Helpers // Ensure this path is canonical, // No "dirto/./this", neither "dirt/to/that/" // no .. and each char must be listed as valid in constants + // Encode unsupported chars with '_' rather than the URL fragment marker '#'. public static string FilterFileName(string fileName) { if (fileName==null) return null; @@ -57,7 +58,7 @@ namespace Yavsc.Server.Helpers { if (ValidFileNameChars.Contains(c)) sb.Append(c); - else sb.Append("#" + ((int)c).ToString("D3")); + else sb.Append("_" + ((int)c).ToString("D3")); } return sb.ToString(); } diff --git a/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs b/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs index 0e36a6018..569e4af3d 100644 --- a/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs +++ b/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs @@ -91,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture return file.Id; } - [Fact] - public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller( + string originalName, string storedName) { _fixture.ResetDatabase(); // DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense @@ -105,7 +109,7 @@ public sealed class FileSystemApiTests : IClassFixture using var form = new MultipartFormDataContent(); var fileContent = new ByteArrayContent(payload); fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - form.Add(fileContent, "file", "note.txt"); + form.Add(fileContent, "file", originalName); using var http = NewClient("alice"); var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken); @@ -117,18 +121,17 @@ public sealed class FileSystemApiTests : IClassFixture var item = Assert.Single(doc.RootElement.EnumerateArray()); var fileId = item.GetProperty("fileId").GetInt64(); Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}"); - Assert.Equal("note.txt", item.GetProperty("fileName").GetString()); + Assert.Equal(storedName, item.GetProperty("fileName").GetString()); Assert.False(item.GetProperty("quotaOffense").GetBoolean()); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice")); - Assert.Equal("note.txt", row.Path); + Assert.Equal(storedName, row.Path); Assert.Equal("text/plain", row.ContentType); Assert.Equal(payload.Length, row.Length); - // The file physically landed under {BlogFilesRoot}/alice/note.txt. - Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")), + Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)), "Expected the uploaded file on disk under the owner's personal root"); } diff --git a/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs b/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs new file mode 100644 index 000000000..d445c29e4 --- /dev/null +++ b/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.FileProviders; +using Yavsc.Models; +using Yavsc.Server.Helpers; + +namespace Yavsc.Blogs.Tests; + +public sealed class FilterFileNameTests +{ + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("ABC-123=_~.PDF", "ABC-123=_~.PDF")] + [InlineData("Troisi\u00e8me.PDF", "Troisi_232me.PDF")] + [InlineData("\u00e9\u00e0\u00e7.pdf", "_233_224_231.pdf")] + [InlineData("folder/file\\name.pdf", "folder_047file_092name.pdf")] + public void Filter_encodes_unsupported_characters_without_URL_fragments( + string originalName, string expectedName) + { + var filtered = AbstractFileSystemHelpers.FilterFileName(originalName); + + Assert.Equal(expectedName, filtered); + Assert.DoesNotContain("#", filtered); + Assert.True(filtered.IsValidYavscPath()); + Assert.Equal(filtered, AbstractFileSystemHelpers.FilterFileName(filtered)); + } + + [Fact] + public async Task Uploaded_accented_filename_is_accessible_through_static_files() + { + var root = Path.Combine(Path.GetTempPath(), $"yavsc-filename-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var payload = System.Text.Encoding.UTF8.GetBytes("accented filename regression"); + using var input = new MemoryStream(payload); + var file = new FormFile(input, 0, payload.Length, "file", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF") + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + var user = new ApplicationUser { DiskQuota = 10_000_000 }; + var received = user.ReceiveUserFile(root, file); + Assert.False(received.QuotaOffense); + Assert.Equal("INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF", + received.FileName); + + using var provider = new PhysicalFileProvider(root); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + await using var app = builder.Build(); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = provider, + RequestPath = "/files" + }); + await app.StartAsync(TestContext.Current.CancellationToken); + using var http = app.GetTestClient(); + var uri = new Uri(http.BaseAddress!, $"/files/{received.FileName}"); + Assert.Equal(string.Empty, uri.Fragment); + using var response = await http.GetAsync(uri, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(payload, + await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +}