diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..52d500e18 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# Conventions du projet + +- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer. diff --git a/CHANGELOG.md b/CHANGELOG.md index af5479790..946ee328f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,25 @@ ### Changed +* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; + les liens provisoires des nouveaux billets sont résolus au premier + enregistrement. Save conserve l'édition ouverte et est mis en valeur + lorsque le titre, le corps ou les pièces jointes sont modifiés. + +* [Projet] Instructions Copilot persistantes : mise à jour du changelog + par défaut à chaque commit, avec justification explicite des exceptions. + ### Fixed +* [PostIt] Les liens Markdown des pièces jointes utilisent désormais + le même filtre de nom que l'upload serveur, avant l'encodage URL. + Le libellé conserve le nom original, notamment ses accents. + +* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés + dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour + éviter les fragments d'URL et permettre le téléchargement via les + fichiers statiques, notamment pour les noms accentués. + * [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont désormais enregistrés côté serveur pour les requêtes authentifiées : le middleware de télémétrie est maintenant branché après diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 112c11752..741b3863c 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 946d3d28e..c9d7d3aae 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,8 +20,13 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [Fact] - public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + [InlineData("rapport final.pdf", "rapport%20final.pdf")] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority( + string originalName, string urlName) { const string authority = "https://idp.example.test"; @@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - "note.txt", + originalName, System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); + Assert.Empty(recorder.Calls); + var draftBeforeSave = vm.DraftArticle; + await vm.SaveAsync(); - // The update branch issues two PUTs: the multipart one - // carrying the file, then — because TryAppendAttachmentLinks - // appended a link — a JSON one carrying the final article. - var linkPut = recorder.Calls.LastOrDefault( - c => c.method == HttpMethod.Put); + var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; - var sentStringContent = multipartContent.First() as StringContent ; + var sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + using var document = System.Text.Json.JsonDocument.Parse(sentString); + var article = document.RootElement.GetProperty("article").GetString(); + Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", sentString); + Assert.DoesNotContain("](/files/", article); + Assert.Equal(draftBeforeSave, vm.DraftArticle); + Assert.NotNull(vm.SelectedPost); + Assert.False(vm.IsDraftModified); + } + + [Fact] + public async Task New_post_resolves_only_the_provisional_link_at_first_save() + { + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); + vm.DraftTitle = "Nouveau billet"; + vm.DraftArticle = "Texte conservé."; + vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); + + Assert.Contains("postit-attachment:", vm.DraftArticle); + Assert.Empty(recorder.Calls); + await vm.SaveAsync(); + + Assert.Equal("Nouveau billet", vm.DraftTitle); + Assert.Contains("Texte conservé.", vm.DraftArticle); + Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", + vm.DraftArticle); + Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); + Assert.Equal(42, vm.SelectedPost?.Id); + Assert.Empty(vm.DraftAttachments); + Assert.False(vm.IsDraftModified); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); } } diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index bf7023997..9a1247657 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -12,6 +12,91 @@ namespace PostIt.Tests; public class PostItViewModelTests { + [Fact] + public void Draft_modified_tracks_title_body_and_attachments_not_publication() + { + var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/")); + vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + Assert.False(vm.IsDraftModified); + vm.DraftIsPublished = true; + Assert.False(vm.IsDraftModified); + vm.DraftTitle = "Autre"; + Assert.True(vm.IsDraftModified); + vm.DraftTitle = "Titre"; + Assert.False(vm.IsDraftModified); + vm.DraftArticleDocument!.Insert(0, "Edit "); + Assert.True(vm.IsDraftModified); + vm.DraftArticle = "Corps"; + Assert.False(vm.IsDraftModified); + vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 })); + Assert.True(vm.IsDraftModified); + } + + [AvaloniaFact] + public async Task Save_keeps_selection_and_editor_open_and_resets_highlight() + { + var recorder = new CallRecorder(); + var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder), + "https://blogs.example.test/api/v1/")); + var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + vm.Posts.Add(selected); + vm.SearchText = ""; + vm.SelectedPost = selected; + var page = new BlogsPage { DataContext = vm }; + var navigation = new NavigationPage(); + await navigation.PushAsync(page); + var window = new Window { Content = navigation }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + selected = Assert.Single(vm.FilteredPosts); + selected.AuthorId = "tester"; + selected.Title = "Titre"; + selected.Article = "Corps"; + page.PostsListBox.SelectedItem = selected; + Dispatcher.UIThread.RunJobs(); + Assert.Same(selected, page.PostsListBox.SelectedItem); + Assert.Same(selected, vm.SelectedPost); + Assert.DoesNotContain("modified", page.SaveButton.Classes); + vm.DraftTitle = "Titre modifié"; + Dispatcher.UIThread.RunJobs(); + Assert.Contains("modified", page.SaveButton.Classes); + Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight); + Assert.Same(vm.SaveCommand, page.SaveButton.Command); + await vm.SaveAsync(); + Dispatcher.UIThread.RunJobs(); + Assert.Same(page, navigation.NavigationStack.Last()); + Assert.Same(selected, vm.SelectedPost); + Assert.Same(selected, page.PostsListBox.SelectedItem); + Assert.Equal("Titre modifié", vm.DraftTitle); + Assert.Equal("Corps", vm.DraftArticle); + Assert.DoesNotContain("modified", page.SaveButton.Classes); + } + finally + { + window.Close(); + } + } + + [Fact] + public async Task Failed_save_preserves_draft_attachments_and_modified_state() + { + var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/")); + var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" }; + vm.SelectedPost = post; + vm.DraftTitle = "Titre modifié"; + vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 })); + var article = vm.DraftArticle; + await vm.SaveAsync(); + Assert.Same(post, vm.SelectedPost); + Assert.Equal("Titre modifié", vm.DraftTitle); + Assert.Equal(article, vm.DraftArticle); + Assert.Single(vm.DraftAttachments); + Assert.True(vm.IsDraftModified); + Assert.False(vm.IsBusy); + } + [AvaloniaFact] public void Published_badge_tracks_post_publication_state() { diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index 882b5d19e..2e3c34a0f 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Abstract.Files; +using Yavsc.Server.Helpers; using AvaloniaEdit.Document; namespace PostIt.ViewModels; @@ -88,12 +90,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } OnPropertyChanged(nameof(DraftArticleDocument)); + UpdateDraftModified(); } } private void DraftArticleDocument_TextChanged(object? sender, EventArgs e) { OnPropertyChanged(nameof(DraftArticle)); + UpdateDraftModified(); } /// Editor buffer for the post's publication state. @@ -107,6 +111,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel /// mutable field. Toggling is its own action. [ObservableProperty] public partial bool DraftIsPublished { get; set; } + + [ObservableProperty] + public partial bool IsDraftModified { get; private set; } + + private bool _preserveDraft; + private string _savedTitle = string.Empty; + private string _savedArticle = string.Empty; + private readonly Dictionary _pendingAttachmentLinks = new(); public bool IsLoaded { get; private set; } public Settings SettingsModel { get; } @@ -156,7 +168,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel }); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanSave))] internal async Task SaveAsync() { // The button is already disabled when the title is empty @@ -174,16 +186,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel { var attachments = DraftAttachments.ToArray(); - // Build a fresh BlogPostDto from the editor buffer on - // every Save — we no longer mutate SelectedPost in - // place. The previous behavior copied the buffer - // (which was a no-op when SelectedPost was null) - // back onto the model and relied on a - // [Required] violation to surface the missing - // input; the new shape keeps the editor buffer as - // the single source of truth for outgoing payloads - // and the selected post as a read-only hint for - // the update path. if (SelectedPost is null || SelectedPost.Id == 0) { var draft = new BlogPostDto @@ -194,12 +196,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, IsPublished = DraftIsPublished }; + var savedTitle = draft.Title; + var savedArticle = draft.Article; var created = await BlogClient!.CreatePostAsync(draft, attachments); if (created is not null) { - SelectedPost = created; + _preserveDraft = true; + try + { + SelectedPost = created; + } + finally + { + _preserveDraft = false; + } - if (TryAppendAttachmentLinks(created, attachments)) + if (ResolvePendingAttachmentLinks(created)) { var linkUpdate = new BlogPostDto { @@ -211,16 +223,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateCreated = created.DateCreated, DateModified = DateTime.UtcNow, }; + savedTitle = linkUpdate.Title; + savedArticle = linkUpdate.Article; await BlogClient.UpdatePostAsync(created.Id, linkUpdate); } + created.Title = savedTitle; + created.Article = savedArticle; this.SetInfoStatus($"Billet {created.Id} créé."); DraftAttachments.Clear(); + MarkDraftSaved(savedTitle, savedArticle); } + else + throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé."); } else { - TryAppendAttachmentLinks(SelectedPost, attachments); var update = new BlogPostDto { Id = SelectedPost.Id, @@ -232,8 +250,11 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DateModified = DateTime.UtcNow, }; await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); + SelectedPost.Title = update.Title; + SelectedPost.Article = update.Article; this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); DraftAttachments.Clear(); + MarkDraftSaved(update.Title, update.Article); } await RefreshPostsAsync(); @@ -429,6 +450,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); DraftAttachments = new ObservableCollection(); + DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged; SelectedPost = null; IsBusy = false; this.SetInfoStatus("Prêt."); @@ -440,6 +462,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel DraftArticle = string.Empty; DraftIsPublished = false; + MarkDraftSaved(); IsLoaded = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor @@ -500,6 +523,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel partial void OnSelectedPostChanged(BlogPostDto? value) { + if (_preserveDraft) return; // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content // when the user clicks a post in the list. When the @@ -513,6 +537,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // null selection so a fresh draft starts unpublished. DraftIsPublished = value?.IsPublished ?? false; DraftAttachments.Clear(); + _pendingAttachmentLinks.Clear(); + MarkDraftSaved(); UpdateCommandStates(); } @@ -521,7 +547,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // Save's CanExecute depends on the buffer: the button must // enable as soon as the user has typed a non-whitespace // title, regardless of whether a post is selected. - partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); + partial void OnDraftTitleChanged(string value) + { + SaveCommand.NotifyCanExecuteChanged(); + UpdateDraftModified(); + } + + private void UpdateDraftModified() + { + IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal) + || !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal) + || (DraftAttachments?.Count ?? 0) > 0; + } + + private void MarkDraftSaved() + => MarkDraftSaved(DraftTitle, DraftArticle); + + private void MarkDraftSaved(string? title, string? article) + { + _savedTitle = title ?? string.Empty; + _savedArticle = article ?? string.Empty; + UpdateDraftModified(); + } private async Task RefreshPostsAsync() @@ -534,10 +581,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } ApplyFilter(); - if (SelectedPost is not null) - { - SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost; - } } private void ApplyFilter() @@ -552,10 +595,29 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) .OrderByDescending(p => p.DateModified); - FilteredPosts.Clear(); - foreach (var post in filtered) + var selection = SelectedPost; + var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray(); + _preserveDraft = true; + try { - FilteredPosts.Add(post); + for (var i = FilteredPosts.Count - 1; i >= 0; i--) + { + if (!visiblePosts.Contains(FilteredPosts[i])) + FilteredPosts.RemoveAt(i); + } + for (var i = 0; i < visiblePosts.Length; i++) + { + var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]); + if (previousIndex < 0) + FilteredPosts.Insert(i, visiblePosts[i]); + else if (previousIndex != i) + FilteredPosts.Move(previousIndex, i); + } + SelectedPost = selection; + } + finally + { + _preserveDraft = false; } } @@ -594,40 +656,53 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } } - private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection attachments) + private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - if (attachments.Count == 0) - return false; - - var ownerSegment = post.Author?.UserName; - if (string.IsNullOrWhiteSpace(ownerSegment)) - ownerSegment = post.AuthorId; - - if (string.IsNullOrWhiteSpace(ownerSegment)) - return false; - - var article = DraftArticle ?? string.Empty; - var links = new List(); - - foreach (var attachment in attachments) + if (e.NewItems is not null) { - var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}"; - var fileUrl = ResolveUserFileUrl(relativePath); - var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; - - if (!article.Contains(markdownLine, StringComparison.Ordinal)) - links.Add(markdownLine); + foreach (BlogUploadFile attachment in e.NewItems) + { + var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName); + string fileUrl; + if (SelectedPost is { Id: > 0 } post) + fileUrl = AttachmentUrl(post, storedName); + else + { + fileUrl = $"postit-attachment:{Guid.NewGuid():N}"; + _pendingAttachmentLinks.Add(fileUrl, storedName); + } + var article = DraftArticle ?? string.Empty; + var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; + if (!article.Contains(markdownLine, StringComparison.Ordinal)) + { + var prefix = article.Length == 0 ? "" + : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); + DraftArticle = article + prefix + markdownLine; + } + } } + UpdateDraftModified(); + } - if (links.Count == 0) - return false; + private string AttachmentUrl(BlogPostDto post, string storedName) + { + var owner = post.Author?.UserName ?? post.AuthorId; + if (string.IsNullOrWhiteSpace(owner)) + throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier."); + return ResolveUserFileUrl( + $"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}"); + } - var prefix = article.Length == 0 - ? "" - : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); - - DraftArticle = article + prefix + string.Join("\n", links); - return true; + private bool ResolvePendingAttachmentLinks(BlogPostDto post) + { + var article = DraftArticle ?? string.Empty; + foreach (var link in _pendingAttachmentLinks) + article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})", + StringComparison.Ordinal); + var changed = article != DraftArticle; + if (changed) DraftArticle = article; + _pendingAttachmentLinks.Clear(); + return changed; } private string ResolveUserFileUrl(string relativePath) @@ -636,7 +711,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel if (!string.IsNullOrWhiteSpace(authority) && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) { - return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString(); + return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri; } return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 7da9a4ce8..4d0858060 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -17,6 +17,13 @@ + + + @@ -60,6 +69,7 @@ @@ -118,6 +128,7 @@ BorderThickness="1"> diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index b40021276..5fab966ca 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -50,7 +50,6 @@ public partial class BlogsPage : ContentPage uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name))); } - vm.DraftAttachments.Clear(); foreach (var upload in uploads) vm.DraftAttachments.Add(upload); } 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/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 79aaad6fb..ff4a657dd 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -1046,7 +1046,6 @@ public static class HostingExtensions app.UseSwagger(); app.UseSwaggerUI(); - app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseIdentityServer(); 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); + } + } +}