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:
parent
446b595077
commit
2765d3eef8
3 changed files with 88 additions and 8 deletions
|
|
@ -49,6 +49,7 @@ namespace Yavsc.Server.Helpers
|
||||||
// Ensure this path is canonical,
|
// Ensure this path is canonical,
|
||||||
// No "dirto/./this", neither "dirt/to/that/"
|
// No "dirto/./this", neither "dirt/to/that/"
|
||||||
// no .. and each char must be listed as valid in constants
|
// 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)
|
public static string FilterFileName(string fileName)
|
||||||
{
|
{
|
||||||
if (fileName==null) return null;
|
if (fileName==null) return null;
|
||||||
|
|
@ -57,7 +58,7 @@ namespace Yavsc.Server.Helpers
|
||||||
{
|
{
|
||||||
if (ValidFileNameChars.Contains(c))
|
if (ValidFileNameChars.Contains(c))
|
||||||
sb.Append(c);
|
sb.Append(c);
|
||||||
else sb.Append("#" + ((int)c).ToString("D3"));
|
else sb.Append("_" + ((int)c).ToString("D3"));
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
return file.Id;
|
return file.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
[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();
|
_fixture.ResetDatabase();
|
||||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||||
|
|
@ -105,7 +109,7 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
using var form = new MultipartFormDataContent();
|
using var form = new MultipartFormDataContent();
|
||||||
var fileContent = new ByteArrayContent(payload);
|
var fileContent = new ByteArrayContent(payload);
|
||||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||||
form.Add(fileContent, "file", "note.txt");
|
form.Add(fileContent, "file", originalName);
|
||||||
|
|
||||||
using var http = NewClient("alice");
|
using var http = NewClient("alice");
|
||||||
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
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 item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||||
var fileId = item.GetProperty("fileId").GetInt64();
|
var fileId = item.GetProperty("fileId").GetInt64();
|
||||||
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
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());
|
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||||
|
|
||||||
using var scope = _fixture.Services.CreateScope();
|
using var scope = _fixture.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
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("text/plain", row.ContentType);
|
||||||
Assert.Equal(payload.Length, row.Length);
|
Assert.Equal(payload.Length, row.Length);
|
||||||
|
|
||||||
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)),
|
||||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
|
||||||
"Expected the uploaded file on disk under the owner's personal root");
|
"Expected the uploaded file on disk under the owner's personal root");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue