diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 52d500e18..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 946ee328f..af5479790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,25 +19,8 @@ ### 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 741b3863c..112c11752 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,14 +73,6 @@ 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 c9d7d3aae..946d3d28e 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,13 +20,8 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [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) + [Fact] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() { const string authority = "https://idp.example.test"; @@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - originalName, + "note.txt", 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(); - var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); + // 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); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var sentStringContent = Assert.IsType(multipartContent.First()); + var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; + var sentStringContent = multipartContent.First() as StringContent ; var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var document = System.Text.Json.JsonDocument.Parse(sentString); - var article = document.RootElement.GetProperty("article").GetString(); - Assert.Contains($"- [{originalName}]({expectedUrl})", article); + Assert.Contains($"- [note.txt]({expectedUrl})", sentString); // The regression shape: a relative, authority-less link. - 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); + Assert.DoesNotContain("](/files/", sentString); } } diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 9a1247657..bf7023997 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -12,91 +12,6 @@ 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 2e3c34a0f..882b5d19e 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -1,7 +1,6 @@ using System; using System.Collections.ObjectModel; using System.Collections.Generic; -using System.Collections.Specialized; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -11,7 +10,6 @@ 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; @@ -90,14 +88,12 @@ 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. @@ -111,14 +107,6 @@ 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; } @@ -168,7 +156,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel }); } - [RelayCommand(CanExecute = nameof(CanSave))] + [RelayCommand] internal async Task SaveAsync() { // The button is already disabled when the title is empty @@ -186,6 +174,16 @@ 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 @@ -196,22 +194,12 @@ 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) { - _preserveDraft = true; - try - { - SelectedPost = created; - } - finally - { - _preserveDraft = false; - } + SelectedPost = created; - if (ResolvePendingAttachmentLinks(created)) + if (TryAppendAttachmentLinks(created, attachments)) { var linkUpdate = new BlogPostDto { @@ -223,22 +211,16 @@ 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, @@ -250,11 +232,8 @@ 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(); @@ -450,7 +429,6 @@ 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."); @@ -462,7 +440,6 @@ 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 @@ -523,7 +500,6 @@ 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 @@ -537,8 +513,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel // null selection so a fresh draft starts unpublished. DraftIsPublished = value?.IsPublished ?? false; DraftAttachments.Clear(); - _pendingAttachmentLinks.Clear(); - MarkDraftSaved(); UpdateCommandStates(); } @@ -547,28 +521,7 @@ 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(); - 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(); - } + partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); private async Task RefreshPostsAsync() @@ -581,6 +534,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } ApplyFilter(); + if (SelectedPost is not null) + { + SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost; + } } private void ApplyFilter() @@ -595,29 +552,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) .OrderByDescending(p => p.DateModified); - var selection = SelectedPost; - var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray(); - _preserveDraft = true; - try + FilteredPosts.Clear(); + foreach (var post in filtered) { - 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; + FilteredPosts.Add(post); } } @@ -656,53 +594,40 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } } - private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection attachments) { - if (e.NewItems is not null) - { - 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 (attachments.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 ownerSegment = post.Author?.UserName; + if (string.IsNullOrWhiteSpace(ownerSegment)) + ownerSegment = post.AuthorId; + + if (string.IsNullOrWhiteSpace(ownerSegment)) + return false; - 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; + var links = new List(); + + foreach (var attachment in attachments) + { + 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); + } + + if (links.Count == 0) + return false; + + var prefix = article.Length == 0 + ? "" + : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); + + DraftArticle = article + prefix + string.Join("\n", links); + return true; } private string ResolveUserFileUrl(string relativePath) @@ -711,7 +636,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel if (!string.IsNullOrWhiteSpace(authority) && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) { - return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri; + return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString(); } return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 4d0858060..7da9a4ce8 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -17,13 +17,6 @@ - - - @@ -69,7 +60,6 @@ @@ -128,7 +118,6 @@ BorderThickness="1"> diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index 5fab966ca..b40021276 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -50,6 +50,7 @@ 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 46eedb571..7665df8ab 100644 --- a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs +++ b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs @@ -49,7 +49,6 @@ 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; @@ -58,7 +57,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 ff4a657dd..79aaad6fb 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -1046,6 +1046,7 @@ 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 569e4af3d..0e36a6018 100644 --- a/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs +++ b/src/test/Yavsc.Blogs.Tests/FileSystemApiTests.cs @@ -91,12 +91,8 @@ public sealed class FileSystemApiTests : IClassFixture return file.Id; } - [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) + [Fact] + public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller() { _fixture.ResetDatabase(); // DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense @@ -109,7 +105,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", originalName); + form.Add(fileContent, "file", "note.txt"); using var http = NewClient("alice"); var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken); @@ -121,17 +117,18 @@ 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(storedName, item.GetProperty("fileName").GetString()); + Assert.Equal("note.txt", 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(storedName, row.Path); + Assert.Equal("note.txt", row.Path); Assert.Equal("text/plain", row.ContentType); Assert.Equal(payload.Length, row.Length); - Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)), + // The file physically landed under {BlogFilesRoot}/alice/note.txt. + Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")), "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 deleted file mode 100644 index d445c29e4..000000000 --- a/src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -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); - } - } -}