Compare commits

...
Author SHA1 Message Date
4ec169ed71
Improve PostIt draft attachment and save workflow
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 26m22s
Insert attachment links on file addition, resolve provisional links on first save, preserve the editor selection, and highlight unsaved draft changes. Include regression tests and changelog updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 02:03:48 +01:00
63b44b5a92
Fix PostIt attachment links to match uploaded filenames
Apply the shared filename filter before URL encoding, preserve escaped URLs and original link labels, and add regression coverage for accented and spaced filenames. Update the changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:38:25 +01:00
96ff4af440
Document changelog updates in project instructions
Require changelog updates by default for each commit and record the uploaded filename encoding fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:21:02 +01:00
79ba934bce
UseStaticFiles was called twice 2026-10-11 01:18:10 +01:00
2765d3eef8
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>
2026-10-11 01:17:26 +01:00
12 changed files with 389 additions and 75 deletions

3
.github/copilot-instructions.md vendored Normal file
View file

@ -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.

View file

@ -19,8 +19,25 @@
### Changed ### 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 ### 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 * [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 : 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 le middleware de télémétrie est maintenant branché après

View file

@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{ {
_recorder.Calls.Add((method, path, contentFactory)); _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)!); return Task.FromResult(default(T)!);
} }
} }

View file

@ -20,8 +20,13 @@ namespace PostIt.Tests;
/// </summary> /// </summary>
public class BlogAttachmentLinkTests public class BlogAttachmentLinkTests
{ {
[Fact] [Theory]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() [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"; const string authority = "https://idp.example.test";
@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests
vm.DraftTitle = "Après"; vm.DraftTitle = "Après";
vm.DraftArticle = "Contenu modifié."; vm.DraftArticle = "Contenu modifié.";
vm.DraftAttachments.Add(new BlogUploadFile( vm.DraftAttachments.Add(new BlogUploadFile(
"note.txt", originalName,
System.Text.Encoding.UTF8.GetBytes("payload test"), System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain")); "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(); await vm.SaveAsync();
// The update branch issues two PUTs: the multipart one var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
// 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); Assert.NotEqual(default, linkPut);
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body); var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent()); var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
var sentStringContent = multipartContent.First() as StringContent ;
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); 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. // 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);
} }
} }

View file

@ -12,6 +12,91 @@ namespace PostIt.Tests;
public class PostItViewModelTests 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] [AvaloniaFact]
public void Published_badge_tracks_post_publication_state() public void Published_badge_tracks_post_publication_state()
{ {

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using Yavsc.Server.Helpers;
using AvaloniaEdit.Document; using AvaloniaEdit.Document;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -88,12 +90,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
} }
OnPropertyChanged(nameof(DraftArticleDocument)); OnPropertyChanged(nameof(DraftArticleDocument));
UpdateDraftModified();
} }
} }
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e) private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
{ {
OnPropertyChanged(nameof(DraftArticle)); OnPropertyChanged(nameof(DraftArticle));
UpdateDraftModified();
} }
/// <summary>Editor buffer for the post's publication state. /// <summary>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.</summary> /// mutable field. Toggling is its own action.</summary>
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } 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<string, string> _pendingAttachmentLinks = new();
public bool IsLoaded { get; private set; } public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
@ -156,7 +168,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}); });
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanSave))]
internal async Task SaveAsync() internal async Task SaveAsync()
{ {
// The button is already disabled when the title is empty // The button is already disabled when the title is empty
@ -174,16 +186,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
{ {
var attachments = DraftAttachments.ToArray(); 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) if (SelectedPost is null || SelectedPost.Id == 0)
{ {
var draft = new BlogPostDto var draft = new BlogPostDto
@ -194,12 +196,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished IsPublished = DraftIsPublished
}; };
var savedTitle = draft.Title;
var savedArticle = draft.Article;
var created = await BlogClient!.CreatePostAsync(draft, attachments); var created = await BlogClient!.CreatePostAsync(draft, attachments);
if (created is not null) 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 var linkUpdate = new BlogPostDto
{ {
@ -211,16 +223,22 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = created.DateCreated, DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
savedTitle = linkUpdate.Title;
savedArticle = linkUpdate.Article;
await BlogClient.UpdatePostAsync(created.Id, linkUpdate); await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
} }
created.Title = savedTitle;
created.Article = savedArticle;
this.SetInfoStatus($"Billet {created.Id} créé."); this.SetInfoStatus($"Billet {created.Id} créé.");
DraftAttachments.Clear(); DraftAttachments.Clear();
MarkDraftSaved(savedTitle, savedArticle);
} }
else
throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé.");
} }
else else
{ {
TryAppendAttachmentLinks(SelectedPost, attachments);
var update = new BlogPostDto var update = new BlogPostDto
{ {
Id = SelectedPost.Id, Id = SelectedPost.Id,
@ -232,8 +250,11 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments); await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
SelectedPost.Title = update.Title;
SelectedPost.Article = update.Article;
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré."); this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear(); DraftAttachments.Clear();
MarkDraftSaved(update.Title, update.Article);
} }
await RefreshPostsAsync(); await RefreshPostsAsync();
@ -429,6 +450,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
Posts = new ObservableCollection<BlogPostDto>(); Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>(); FilteredPosts = new ObservableCollection<BlogPostDto>();
DraftAttachments = new ObservableCollection<BlogUploadFile>(); DraftAttachments = new ObservableCollection<BlogUploadFile>();
DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged;
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
this.SetInfoStatus("Prêt."); this.SetInfoStatus("Prêt.");
@ -440,6 +462,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
MarkDraftSaved();
IsLoaded = false; IsLoaded = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor // 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) partial void OnSelectedPostChanged(BlogPostDto? value)
{ {
if (_preserveDraft) return;
// Mirror the selection into the editor buffer so the // Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content // XAML-bound TextBox/TextEditor show the right content
// when the user clicks a post in the list. When the // 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. // null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false; DraftIsPublished = value?.IsPublished ?? false;
DraftAttachments.Clear(); DraftAttachments.Clear();
_pendingAttachmentLinks.Clear();
MarkDraftSaved();
UpdateCommandStates(); UpdateCommandStates();
} }
@ -521,7 +547,28 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// Save's CanExecute depends on the buffer: the button must // Save's CanExecute depends on the buffer: the button must
// enable as soon as the user has typed a non-whitespace // enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected. // 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() private async Task RefreshPostsAsync()
@ -534,10 +581,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
} }
ApplyFilter(); ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
} }
private void ApplyFilter() private void ApplyFilter()
@ -552,10 +595,29 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) || p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified); .OrderByDescending(p => p.DateModified);
FilteredPosts.Clear(); var selection = SelectedPost;
foreach (var post in filtered) 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<BlogUploadFile> attachments) private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
if (attachments.Count == 0) if (e.NewItems is not null)
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<string>();
foreach (var attachment in attachments)
{ {
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}"; foreach (BlogUploadFile attachment in e.NewItems)
var fileUrl = ResolveUserFileUrl(relativePath); {
var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
string fileUrl;
if (!article.Contains(markdownLine, StringComparison.Ordinal)) if (SelectedPost is { Id: > 0 } post)
links.Add(markdownLine); 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) private string AttachmentUrl(BlogPostDto post, string storedName)
return false; {
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 private bool ResolvePendingAttachmentLinks(BlogPostDto post)
? "" {
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); var article = DraftArticle ?? string.Empty;
foreach (var link in _pendingAttachmentLinks)
DraftArticle = article + prefix + string.Join("\n", links); article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
return true; StringComparison.Ordinal);
var changed = article != DraftArticle;
if (changed) DraftArticle = article;
_pendingAttachmentLinks.Clear();
return changed;
} }
private string ResolveUserFileUrl(string relativePath) private string ResolveUserFileUrl(string relativePath)
@ -636,7 +711,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
if (!string.IsNullOrWhiteSpace(authority) if (!string.IsNullOrWhiteSpace(authority)
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) && 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}"; return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";

View file

@ -17,6 +17,13 @@
<Design.DataContext> <Design.DataContext>
<vm:BlogsViewModel /> <vm:BlogsViewModel />
</Design.DataContext> </Design.DataContext>
<ContentPage.Styles>
<Style Selector="CommandBarButton.modified">
<Setter Property="Background" Value="#FFF0C2" />
<Setter Property="Foreground" Value="#663C00" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
</ContentPage.Styles>
<Grid <Grid
RowDefinitions="Auto,Auto,*" RowDefinitions="Auto,Auto,*"
Margin="5,5,10,5" Margin="5,5,10,5"
@ -46,7 +53,9 @@
</CommandBarButton> </CommandBarButton>
<CommandBarButton <CommandBarButton
x:Name="SaveButton" x:Name="SaveButton"
Command="{Binding SaveAsync}" Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
Label="Save"> Label="Save">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" /> <PathIcon Data="{StaticResource SaveIcon}" />
@ -60,6 +69,7 @@
<CommandBarButton <CommandBarButton
x:Name="AddAttachmentButton" x:Name="AddAttachmentButton"
Click="AddAttachment_Click" Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers"> Label="Ajouter fichiers">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" /> <PathIcon Data="{StaticResource AddIcon}" />
@ -118,6 +128,7 @@
BorderThickness="1"> BorderThickness="1">
<ListBox <ListBox
x:Name="PostsListBox" x:Name="PostsListBox"
IsEnabled="{Binding !IsBusy}"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"> ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">

View file

@ -50,7 +50,6 @@ public partial class BlogsPage : ContentPage
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name))); uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
} }
vm.DraftAttachments.Clear();
foreach (var upload in uploads) foreach (var upload in uploads)
vm.DraftAttachments.Add(upload); vm.DraftAttachments.Add(upload);
} }

View file

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

View file

@ -1046,7 +1046,6 @@ public static class HostingExtensions
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseIdentityServer(); app.UseIdentityServer();

View file

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

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