From c1254a522fc2941f035eacfd8a77860013ebeaf9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 16 Oct 2026 13:31:57 +0100 Subject: [PATCH] feat(PostIt): add dedicated blog reader and video playback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 12 ++ doc/architecture/postit.md | 43 ++++ src/PostIt/Directory.Packages.props | 1 + src/PostIt/PostIt.Tests/BlogReaderTests.cs | 201 ++++++++++++++++++ .../PostIt.Tests/BlogResourceServiceTests.cs | 137 ++++++++++++ src/PostIt/PostIt.Tests/MainPageSaveTests.cs | 1 + .../PostIt.Tests/MarkdownEditorScrollTests.cs | 2 +- .../Helpers/ServiceCollectionHelpers.cs | 1 + src/PostIt/PostIt/PostIt.csproj | 1 + .../PostIt/Services/BlogImageResolver.cs | 33 +++ .../PostIt/Services/BlogResourceService.cs | 98 +++++++++ src/PostIt/PostIt/ViewLocator.cs | 1 + .../PostIt/ViewModels/Blogs/BlogsViewModel.cs | 106 ++++++++- .../ViewModels/Blogs/VideoPageViewModel.cs | 12 ++ src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 64 ++++-- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 7 +- src/PostIt/PostIt/Views/Blogs/VideoPage.axaml | 19 ++ .../PostIt/Views/Blogs/VideoPage.axaml.cs | 108 ++++++++++ 18 files changed, 827 insertions(+), 20 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/BlogReaderTests.cs create mode 100644 src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs create mode 100644 src/PostIt/PostIt/Services/BlogImageResolver.cs create mode 100644 src/PostIt/PostIt/Services/BlogResourceService.cs create mode 100644 src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/Blogs/VideoPage.axaml create mode 100644 src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a1ee9ba..7db6bc10b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +* [PostIt] Vue de lecture des billets par défaut, avec titre, auteur, + date et contenu Markdown défilant ; l'édition et la création restent + accessibles par des actions explicites, sans perdre le brouillon en + passant de la lecture à l'édition. +* [PostIt] Résolution des liens et images relatifs depuis + `/files//`, reconnaissance des fichiers + utilisateur sur cette origine et détection du type MIME par HTTP. +* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop, + avec lecture/pause, libération du lecteur à la fermeture et accès au + navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et + les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système. + ### Changed * L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`) diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index b1bf83cf7..37d372d49 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -40,6 +40,49 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop). Toute la logique (VM, services, navigation, settings, OIDC) est dans le code-base partagé. +## Lecture des billets et fichiers utilisateur + +La page des blogs affiche par défaut une vue de lecture : titre, login +de l'auteur, date de modification et contenu Markdown dans une zone +défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et +**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche +le contenu enregistré sans effacer les modifications non enregistrées ; +un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé. + +La base des ressources est dérivée de `Settings.Authentication.Authority` +(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` : +`https:///files//`. Le repli sur `AuthorId` +conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé. +Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ; +un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org. +Les liens absolus ne sont pas réécrits. Les images Markdown `![texte](url)` +sont affichées dans le billet et leur résolveur n'accepte que HTTP(S), +sans accès aux fichiers locaux du poste. + +Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible +Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur +conserve `MarkdownViewer.Core` : son gestionnaire intégré ouvre le navigateur +avant d'émettre l'événement de lien et ne permet donc pas ce routage. + +Au clic sur un fichier sous `/files//...` de la même origine Org +(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec +`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse +`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une +extension connue sert de repli uniquement en l'absence de type exploitable +(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont +affichées dans la barre de statut, sans les masquer par une déduction MIME. + +Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop. +Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et +une action d'ouverture externe. Le lecteur et ses ressources natives sont +arrêtés et libérés à la sortie de la page. **LibVLC doit être installé** +sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les +bibliothèques natives adaptées sont nécessaires sous Windows/macOS). +Une absence de LibVLC ou une erreur de décodage est signalée dans la page. +Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible, +les vidéos sont ouvertes dans le navigateur, comme les autres fichiers +(images liées, PDF, téléchargements) et les liens externes. + ## ViewLocator custo Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index a353e4b3a..f0b523488 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs new file mode 100644 index 000000000..e40ae61c5 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BlogReaderTests.cs @@ -0,0 +1,201 @@ +using System.Net; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Markdown.Avalonia; +using ColorTextBlock.Avalonia; +using PostIt.Models; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using PostIt.Views.Blogs; +using Yavsc.Api.Client; +using Yavsc.Blogspot; + +namespace PostIt.Tests; + +public class BlogReaderTests +{ + private static BlogPostDto Post(string article = "Texte du billet.") => new() + { + Id = 42, + Title = "Un billet à lire", + Article = article, + AuthorId = "user-id-not-login", + Author = new BlogPostAuthorDto { UserName = "paul" }, + DateModified = DateTime.UtcNow + }; + + private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null) + => new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } }, + resources: resources); + + [Fact] + public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login() + { + var vm = CreateViewModel(); + vm.SelectedPost = Post(); + Assert.False(vm.IsEditing); + Assert.Equal("paul", vm.ReaderAuthor); + Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl); + Assert.Equal("Texte du billet.", vm.ReaderArticle); + } + + [Fact] + public void Reading_does_not_publish_or_discard_unsaved_editor_changes() + { + var vm = CreateViewModel(); + vm.SelectedPost = Post(); + vm.EditPostCommand.Execute(null); + vm.DraftArticle = "Modifications non enregistrées"; + vm.ReadPostCommand.Execute(null); + Assert.False(vm.IsEditing); + Assert.Equal("Texte du billet.", vm.ReaderArticle); + Assert.Equal("Modifications non enregistrées", vm.DraftArticle); + Assert.True(vm.IsDraftModified); + vm.EditPostCommand.Execute(null); + Assert.True(vm.IsEditing); + Assert.Equal("Modifications non enregistrées", vm.DraftArticle); + } + + [Fact] + public void New_post_is_an_explicit_action_and_protects_unsaved_changes() + { + var vm = CreateViewModel(); + vm.SelectedPost = Post(); + vm.NewPostCommand.Execute(null); + Assert.True(vm.IsEditing); + Assert.Null(vm.SelectedPost); + Assert.Empty(vm.DraftTitle); + Assert.Empty(vm.DraftArticle!); + vm.DraftTitle = "Nouveau brouillon"; + vm.NewPostCommand.Execute(null); + Assert.Equal("Nouveau brouillon", vm.DraftTitle); + Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity); + } + + [AvaloniaFact] + public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application() + { + var vm = CreateViewModel(); + await vm.InitializeAsync(); + vm.SelectedPost = Post("[Vidéo](clip.mp4)"); + var page = new BlogsPage { DataContext = vm }; + var nav = new NavigationPage(); + await nav.PushAsync(page); + var window = new Window { Width = 800, Height = 700, Content = nav }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Assert.True(page.FindControl("PostReader")!.IsVisible); + Assert.False(page.FindControl("PostEditor")!.IsVisible); + Assert.False(page.SaveButton.IsVisible); + var reader = page.FindControl("ArticleReader")!; + Assert.Equal(vm.ReaderArticle, reader.Markdown); + Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot); + Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom(reader.Engine).HyperlinkCommand); + vm.EditPostCommand.Execute(null); + Assert.False(page.FindControl("PostReader")!.IsVisible); + Assert.True(page.FindControl("PostEditor")!.IsVisible); + Assert.True(page.SaveButton.IsVisible); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed() + { + var vm = CreateViewModel(); + await vm.InitializeAsync(); + vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}."))); + var page = new BlogsPage { DataContext = vm }; + var nav = new NavigationPage(); + await nav.PushAsync(page); + var window = new Window { Width = 800, Height = 700, Content = nav }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + var reader = page.FindControl("ArticleReader")!; + var scroll = reader.GetVisualDescendants().OfType().Single(); + Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height); + Assert.True(scroll.Extent.Height > scroll.Viewport.Height); + scroll.ScrollToEnd(); + Assert.True(scroll.Offset.Y > 0); + vm.SelectedPost = null; + Dispatcher.UIThread.RunJobs(); + Assert.Empty(reader.Markdown!); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel() + { + using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request => + { + Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri); + var response = new HttpResponseMessage(HttpStatusCode.OK); + response.Content.Headers.ContentType = new("video/mp4"); + return response; + })); + var vm = CreateViewModel(new BlogResourceService(client)); + vm.SelectedPost = Post(); + var app = (App)Application.Current!; + var root = new MainView(); + app.AttachMainWindow(root); + var template = new FuncDataTemplate((_, _) => new ContentPage()); + app.DataTemplates.Insert(0, template); + try + { + var page = new BlogsPage { DataContext = vm }; + var reader = page.FindControl("ArticleReader")!; + Dispatcher.UIThread.RunJobs(); + var engine = Assert.IsAssignableFrom(reader.Engine); + var link = Assert.IsType(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)"))); + Assert.NotNull(link.Command); + link.Command(link.CommandParameter!); + Assert.NotNull(vm.OpenResourceCommand.ExecutionTask); + await vm.OpenResourceCommand.ExecutionTask; + var target = Assert.IsType(root.NavRoot.NavigationStack.Last().DataContext); + Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri); + Assert.Equal("video/mp4", target.MimeType); + } + finally + { + app.DataTemplates.Remove(template); + } + } + + [AvaloniaFact] + public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control() + { + var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4"); + var page = Assert.IsType(new ViewLocator().Build(vm)); + Assert.NotNull(page.FindControl("Video")); + } + + [Fact] + public async Task Broken_resource_reports_an_error_and_releases_busy_state() + { + using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound))); + var vm = CreateViewModel(new BlogResourceService(client)); + vm.SelectedPost = Post(); + await vm.OpenResourceCommand.ExecuteAsync("absent.mp4"); + Assert.False(vm.IsBusy); + Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity); + } +} diff --git a/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs b/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs new file mode 100644 index 000000000..0682dc6f2 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http.Headers; +using PostIt.Services; + +namespace PostIt.Tests; + +public class BlogResourceServiceTests +{ + private const string Authority = "https://yavsc.pschneider.fr"; + + [Theory] + [InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")] + [InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")] + [InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")] + [InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")] + [InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")] + [InlineData("//elsewhere.test/page", "https://elsewhere.test/page")] + public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected) + => Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri); + + [Fact] + public void Encodes_login_and_keeps_trailing_slash() + => Assert.Equal(Authority + "/files/paul%20test/", + BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri); + + [Theory] + [InlineData(Authority + "/files/paul/movie.mp4", true)] + [InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)] + [InlineData(Authority + "/files/paul/", false)] + [InlineData(Authority + "/files/paul", false)] + [InlineData(Authority + "/files/", false)] + [InlineData(Authority + "/files-other/paul/movie.mp4", false)] + [InlineData(Authority + "/page.mp4", false)] + [InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)] + [InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)] + [InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)] + [InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)] + [InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)] + public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected) + => Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority)); + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("file:///tmp/video.mp4")] + [InlineData("data:video/mp4;base64,AAAA")] + public void Rejects_non_web_links(string link) + => Assert.Throws(() => BlogResourceService.Resolve(link, Authority, "paul")); + + [Theory] + [InlineData("video/webm", "movie.jpg", "video/webm")] + [InlineData("image/jpeg", "movie.mp4", "image/jpeg")] + [InlineData("application/octet-stream", "movie.MP4", "video/mp4")] + [InlineData(null, "movie.webm", "video/webm")] + [InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")] + [InlineData(null, "file.unknown", "application/octet-stream")] + public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected) + { + using var client = new HttpClient(new StubHandler(request => + { + Assert.Equal(HttpMethod.Head, request.Method); + return Response(HttpStatusCode.OK, mime); + })); + var service = new BlogResourceService(client); + Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"), + TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status) + { + var calls = 0; + using var client = new HttpClient(new StubHandler(request => + { + calls++; + if (request.Method == HttpMethod.Head) + return Response(status, null); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("bytes=0-0", request.Headers.Range?.ToString()); + return Response(HttpStatusCode.PartialContent, "video/mp4"); + })); + Assert.Equal("video/mp4", await new BlogResourceService(client) + .GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken)); + Assert.Equal(2, calls); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status) + { + using var client = new HttpClient(new StubHandler(_ => Response(status, null))); + await Assert.ThrowsAsync(() => new BlogResourceService(client) + .GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")] + [InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")] + [InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")] + public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected) + { + using var client = new HttpClient(new StubHandler(request => + { + Assert.Equal(expected, request.RequestUri!.AbsoluteUri); + return Response(HttpStatusCode.OK, "image/jpeg"); + })); + var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" }; + await using var stream = await resolver.ResolveImageResource(link); + Assert.NotNull(stream); + } + + [Theory] + [InlineData("file:///etc/passwd")] + [InlineData("data:image/png;base64,AAAA")] + public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link) + { + var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" }; + await Assert.ThrowsAsync(() => resolver.ResolveImageResource(link)); + } + + private static HttpResponseMessage Response(HttpStatusCode status, string? mime) + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) }; + if (mime is not null) + response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime); + return response; + } + + internal sealed class StubHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(respond(request)); + } +} diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 519cd141d..7fdd55279 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -42,6 +42,7 @@ public class MainPageSaveTests var api = new RecordingYavscApiClient(recorder); var blog = new BlogApiClient(api, "http://localhost/"); var viewModel = new BlogsViewModel(blog); + viewModel.NewPostCommand.Execute(null); var page = new BlogsPage { DataContext = viewModel }; // MainPage is a ContentPage (a Page, not a Control), so it diff --git a/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs b/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs index 1a83330f9..6cdaa5b5b 100644 --- a/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs +++ b/src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs @@ -55,7 +55,7 @@ public class MarkdownEditorScrollTests [InlineData(true)] public void Blog_page_bounds_the_article_viewport(bool preview) { - var page = new BlogsPage(); + var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } }; var editor = page.GetLogicalDescendants().OfType().Single(); editor.MarkdownText = LongArticle; editor.IsPreviewVisible = preview; diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index c88cbaac5..fc2388949 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -44,6 +44,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Models diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 89525f190..31c5afbda 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -39,6 +39,7 @@ + diff --git a/src/PostIt/PostIt/Services/BlogImageResolver.cs b/src/PostIt/PostIt/Services/BlogImageResolver.cs new file mode 100644 index 000000000..b7048322d --- /dev/null +++ b/src/PostIt/PostIt/Services/BlogImageResolver.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Markdown.Avalonia.Utils; + +namespace PostIt.Services; + +public sealed class BlogImageResolver : IPathResolver +{ + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; + private readonly HttpClient _client; + + public BlogImageResolver() : this(Client) { } + + public BlogImageResolver(HttpClient client) => _client = client; + + public string? AssetPathRoot { get; set; } + public IEnumerable? CallerAssemblyNames { get; set; } + + public async Task ResolveImageResource(string relativeOrAbsolutePath) + { + var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal) + && Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute) + ? absolute + : new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException( + "La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath); + if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) + throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS."); + return await _client.GetStreamAsync(uri); + } +} diff --git a/src/PostIt/PostIt/Services/BlogResourceService.cs b/src/PostIt/PostIt/Services/BlogResourceService.cs new file mode 100644 index 000000000..5cb731521 --- /dev/null +++ b/src/PostIt/PostIt/Services/BlogResourceService.cs @@ -0,0 +1,98 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Abstract.Files; + +namespace PostIt.Services; + +public sealed class BlogResourceService +{ + private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) }; + private readonly HttpClient _client; + + public BlogResourceService() : this(SharedClient) { } + + public BlogResourceService(HttpClient client) => _client = client; + + public static Uri GetUserBaseUri(string authority, string login) + { + if (string.IsNullOrWhiteSpace(login)) + throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers."); + return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/"); + } + + public static Uri Resolve(string link, string authority, string login) + { + if (string.IsNullOrWhiteSpace(link)) + throw new ArgumentException("Le lien est vide.", nameof(link)); + var origin = GetAuthority(authority); + var uri = link.StartsWith("/", StringComparison.Ordinal) + ? new Uri(origin, link) + : Uri.TryCreate(link, UriKind.Absolute, out var absolute) + ? absolute + : new Uri(GetUserBaseUri(authority, login), link); + if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) + throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge."); + return uri; + } + + public static bool IsUserResource(Uri uri, string authority) + { + var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority)); + if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo)) + return false; + var path = uri.AbsolutePath[root.AbsolutePath.Length..]; + var separator = path.IndexOf('/'); + return separator > 0 && separator < path.Length - 1; + } + + public async Task GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default) + { + using var head = new HttpRequestMessage(HttpMethod.Head, uri); + using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented) + { + using var get = new HttpRequestMessage(HttpMethod.Get, uri); + get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0); + using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + fallback.EnsureSuccessStatusCode(); + return GetMimeType(fallback, uri); + } + response.EnsureSuccessStatusCode(); + return GetMimeType(response, uri); + } + + private static string GetMimeType(HttpResponseMessage response, Uri uri) + { + var mime = response.Content.Headers.ContentType?.MediaType; + if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)) + return mime; + return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch + { + ".mp4" or ".m4v" => "video/mp4", + ".webm" => "video/webm", + ".ogv" => "video/ogg", + ".ogg" => "audio/ogg", + ".mov" => "video/quicktime", + ".mkv" => "video/x-matroska", + ".avi" => "video/x-msvideo", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".pdf" => "application/pdf", + _ => mime ?? "application/octet-stream" + }; + } + + private static Uri GetAuthority(string authority) + { + if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) + || !string.IsNullOrEmpty(uri.UserInfo)) + throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification."); + return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/"); + } +} diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 986ddc92f..b8e242753 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -42,6 +42,7 @@ public class ViewLocator : IDataTemplate return data switch { BlogsViewModel => services.GetRequiredService(), + VideoPageViewModel => services.GetRequiredService(), Settings => services.GetRequiredService(), AboutPageViewModel => services.GetRequiredService(), HomePageViewModel => services.GetRequiredService(), diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index 2e3c34a0f..f155d2bff 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -13,6 +13,8 @@ using Yavsc.Api.Client; using Yavsc.Abstract.Files; using Yavsc.Server.Helpers; using AvaloniaEdit.Document; +using Avalonia.Controls; +using PostIt.Services; namespace PostIt.ViewModels; @@ -141,8 +143,85 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel public partial ObservableCollection DraftAttachments { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ReaderArticle))] + [NotifyPropertyChangedFor(nameof(ReaderAuthor))] public partial BlogPostDto? SelectedPost { get; set; } + [ObservableProperty] + public partial bool IsEditing { get; set; } + + [ObservableProperty] + public partial string? ReaderBaseUrl { get; private set; } + + public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty; + public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty; + + private readonly BlogResourceService _resources = new(); + + [RelayCommand] + private void NewPost() + { + if (IsDraftModified) + { + this.SetWarningStatus("Enregistrez vos modifications avant de créer un autre billet."); + return; + } + SelectedPost = null; + DraftTitle = string.Empty; + DraftArticle = string.Empty; + DraftAttachments.Clear(); + _pendingAttachmentLinks.Clear(); + DraftIsPublished = false; + MarkDraftSaved(); + IsEditing = true; + } + + [RelayCommand] + private void EditPost() => IsEditing = true; + + [RelayCommand] + private void ReadPost() + { + IsEditing = false; + UpdateReader(); + } + + [RelayCommand] + private async Task OpenResourceAsync(string? link) + { + try + { + IsBusy = true; + var uri = BlogResourceService.Resolve(link ?? string.Empty, + Settings.Authentication.Authority, ReaderAuthor); + if (BlogResourceService.IsUserResource(uri, Settings.Authentication.Authority)) + { + this.SetInfoStatus("Détection du type de fichier..."); + var mime = await _resources.GetMimeTypeAsync(uri); + if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase) + && !OperatingSystem.IsBrowser() && !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS()) + { + await ((App)Application.Current!).PushPageAsync(new VideoPageViewModel(uri, mime)); + this.SetInfoStatus($"Vidéo : {mime}"); + return; + } + this.SetInfoStatus($"Ouverture dans le navigateur ({mime})."); + } + var topLevel = TopLevel.GetTopLevel(((App)Application.Current!).View!) + ?? throw new InvalidOperationException("La fenêtre de l'application est indisponible."); + if (!await topLevel.Launcher.LaunchUriAsync(uri)) + throw new InvalidOperationException("Le système n'a pas pu ouvrir ce lien."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Impossible d'ouvrir le lien : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + [ObservableProperty] public partial bool IsBusy { get; set; } @@ -258,6 +337,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel } await RefreshPostsAsync(); + UpdateReader(); }); } @@ -503,12 +583,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel /// . Production code uses the /// (Settings, BlogApiClient) overload below. /// - public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) + public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null, + BlogResourceService? resources = null) { WindowTitle = "Blog"; SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Services = services; + _resources = resources ?? new BlogResourceService(); Init(settings); } @@ -523,6 +605,9 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel partial void OnSelectedPostChanged(BlogPostDto? value) { + UpdateReader(); + if (!_preserveDraft) + IsEditing = false; if (_preserveDraft) return; // Mirror the selection into the editor buffer so the // XAML-bound TextBox/TextEditor show the right content @@ -542,6 +627,25 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel UpdateCommandStates(); } + private void UpdateReader() + { + string? baseUrl = null; + try + { + if (SelectedPost is not null) + baseUrl = BlogResourceService.GetUserBaseUri( + Settings.Authentication.Authority, ReaderAuthor).AbsoluteUri; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UriFormatException) + { + this.SetErrorStatus($"Impossible de résoudre les ressources du billet : {ex.Message}"); + } + ReaderBaseUrl = baseUrl; + OnPropertyChanged(nameof(SelectedPost)); + OnPropertyChanged(nameof(ReaderArticle)); + OnPropertyChanged(nameof(ReaderAuthor)); + } + partial void OnIsBusyChanged(bool value) => UpdateCommandStates(); // Save's CanExecute depends on the buffer: the button must diff --git a/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs new file mode 100644 index 000000000..9ce6e8f17 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs @@ -0,0 +1,12 @@ +using System; + +namespace PostIt.ViewModels; + +public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase +{ + public Uri Source { get; } = source; + public string MimeType { get; } = mimeType; + public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath)); + public override bool CanNavigateNext { get => false; protected set { } } + public override bool CanNavigatePrevious { get => true; protected set { } } +} diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 4d0858060..704488123 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -2,13 +2,12 @@ x:Class="PostIt.Views.Blogs.BlogsPage" xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:md="using:MarkdownViewer.Core.Controls" + xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia" + xmlns:services="using:PostIt.Services" xmlns:models="using:Yavsc.Blogspot" xmlns:postitControls="using:PostIt.Controls" - xmlns:views="using:PostIt.Views.Blogs" xmlns:vm="using:PostIt.ViewModels" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" @@ -25,7 +24,7 @@ @@ -36,23 +35,30 @@ + Text="Billets de blog" /> - + + + + - + @@ -77,6 +84,7 @@ @@ -92,6 +100,7 @@ @@ -99,13 +108,14 @@ - + @@ -173,7 +183,8 @@ HorizontalAlignment="Stretch" ResizeDirection="Rows" /> - - @@ -226,9 +236,37 @@ - - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs index 5fab966ca..39688ed24 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs @@ -14,15 +14,12 @@ public partial class BlogsPage : ContentPage InitializeComponent(); } - protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + protected override async void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); if (DataContext is ViewModels.BlogsViewModel vm) { - if (!vm.IsLoaded) - { - vm.RefreshAsync().Wait(); - } + await vm.InitializeAsync(); } } diff --git a/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml new file mode 100644 index 000000000..c00cdf644 --- /dev/null +++ b/src/PostIt/PostIt/Views/Blogs/VideoPage.axaml @@ -0,0 +1,19 @@ + + + + + + + +