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>
This commit is contained in:
Paul Schneider 2026-10-11 01:17:26 +01:00
commit 2765d3eef8
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
3 changed files with 88 additions and 8 deletions

View file

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

View file

@ -91,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
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<BlogsWebServerFixture>
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<BlogsWebServerFixture>
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<ApplicationDbContext>();
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");
}

View file

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