Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4ec169ed71 |
|||
|
63b44b5a92 |
|||
|
96ff4af440 |
|||
|
79ba934bce |
|||
|
2765d3eef8 |
12 changed files with 389 additions and 75 deletions
3
.github/copilot-instructions.md
vendored
Normal file
3
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
17
CHANGELOG.md
17
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
_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)!);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,13 @@ namespace PostIt.Tests;
|
|||
/// </summary>
|
||||
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<System.Func<HttpContent>>(linkPut.body);
|
||||
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
|
||||
|
||||
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
|
||||
var sentStringContent = multipartContent.First() as StringContent ;
|
||||
var sentStringContent = Assert.IsType<StringContent>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
[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<string, string> _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)
|
||||
{
|
||||
_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<BlogPostDto>();
|
||||
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
||||
DraftAttachments = new ObservableCollection<BlogUploadFile>();
|
||||
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<BlogUploadFile> 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;
|
||||
|
||||
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 links = new List<string>();
|
||||
|
||||
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);
|
||||
{
|
||||
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}";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
<Design.DataContext>
|
||||
<vm:BlogsViewModel />
|
||||
</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
|
||||
RowDefinitions="Auto,Auto,*"
|
||||
Margin="5,5,10,5"
|
||||
|
|
@ -46,7 +53,9 @@
|
|||
</CommandBarButton>
|
||||
<CommandBarButton
|
||||
x:Name="SaveButton"
|
||||
Command="{Binding SaveAsync}"
|
||||
Command="{Binding SaveCommand}"
|
||||
Classes.modified="{Binding IsDraftModified}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Save">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||
|
|
@ -60,6 +69,7 @@
|
|||
<CommandBarButton
|
||||
x:Name="AddAttachmentButton"
|
||||
Click="AddAttachment_Click"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Label="Ajouter fichiers">
|
||||
<CommandBarButton.Icon>
|
||||
<PathIcon Data="{StaticResource AddIcon}" />
|
||||
|
|
@ -118,6 +128,7 @@
|
|||
BorderThickness="1">
|
||||
<ListBox
|
||||
x:Name="PostsListBox"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:BlogPostDto">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1046,7 +1046,6 @@ public static class HostingExtensions
|
|||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseIdentityServer();
|
||||
|
|
|
|||
|
|
@ -91,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
return file.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
||||
[Theory]
|
||||
[InlineData("note.txt", "note.txt")]
|
||||
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller(
|
||||
string originalName, string storedName)
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||
|
|
@ -105,7 +109,7 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
using var form = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(payload);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
form.Add(fileContent, "file", originalName);
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||
|
|
@ -117,18 +121,17 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||
var fileId = item.GetProperty("fileId").GetInt64();
|
||||
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
||||
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
|
||||
Assert.Equal(storedName, item.GetProperty("fileName").GetString());
|
||||
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
||||
Assert.Equal("note.txt", row.Path);
|
||||
Assert.Equal(storedName, row.Path);
|
||||
Assert.Equal("text/plain", row.ContentType);
|
||||
Assert.Equal(payload.Length, row.Length);
|
||||
|
||||
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)),
|
||||
"Expected the uploaded file on disk under the owner's personal root");
|
||||
}
|
||||
|
||||
|
|
|
|||
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
public sealed class FilterFileNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("note.txt", "note.txt")]
|
||||
[InlineData("ABC-123=_~.PDF", "ABC-123=_~.PDF")]
|
||||
[InlineData("Troisi\u00e8me.PDF", "Troisi_232me.PDF")]
|
||||
[InlineData("\u00e9\u00e0\u00e7.pdf", "_233_224_231.pdf")]
|
||||
[InlineData("folder/file\\name.pdf", "folder_047file_092name.pdf")]
|
||||
public void Filter_encodes_unsupported_characters_without_URL_fragments(
|
||||
string originalName, string expectedName)
|
||||
{
|
||||
var filtered = AbstractFileSystemHelpers.FilterFileName(originalName);
|
||||
|
||||
Assert.Equal(expectedName, filtered);
|
||||
Assert.DoesNotContain("#", filtered);
|
||||
Assert.True(filtered.IsValidYavscPath());
|
||||
Assert.Equal(filtered, AbstractFileSystemHelpers.FilterFileName(filtered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uploaded_accented_filename_is_accessible_through_static_files()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"yavsc-filename-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("accented filename regression");
|
||||
using var input = new MemoryStream(payload);
|
||||
var file = new FormFile(input, 0, payload.Length, "file",
|
||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF")
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "application/pdf"
|
||||
};
|
||||
var user = new ApplicationUser { DiskQuota = 10_000_000 };
|
||||
var received = user.ReceiveUserFile(root, file);
|
||||
Assert.False(received.QuotaOffense);
|
||||
Assert.Equal("INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF",
|
||||
received.FileName);
|
||||
|
||||
using var provider = new PhysicalFileProvider(root);
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
await using var app = builder.Build();
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
RequestPath = "/files"
|
||||
});
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
using var http = app.GetTestClient();
|
||||
var uri = new Uri(http.BaseAddress!, $"/files/{received.FileName}");
|
||||
Assert.Equal(string.Empty, uri.Fragment);
|
||||
using var response = await http.GetAsync(uri, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(payload,
|
||||
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue