Compare commits

...
Author SHA1 Message Date
7106bb9c61
fix(PostIt): disable links without a reader context
Some checks failed
Forgejo Release / release (push) Failing after 20m8s
Prevent the Markdown viewer's default browser handler from activating when the blog reader has no view model, while keeping reader links bound to PostIt after context changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 16:18:51 +01:00
e0671597a1
fix(PostIt): render embedded blog images reliably
Use seekable streams for embedded images, bind reader commands only when a page view model is present, and avoid empty-selection binding errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 16:14:08 +01:00
dade93bbfc
feat(PostIt): add dedicated blog reader and video playback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-16 13:31:57 +01:00
4fe12b769a
Honor the administration setup 2026-10-11 08:55:46 +01:00
22 changed files with 1031 additions and 22 deletions

View file

@ -1,5 +1,25 @@
# Changelog
## [1.0.8-rc26] - unstable
### 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
`<autorité Yavsc.Org>/files/<login auteur>/`, 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`)
## [1.0.8-rc25] - unstable
### Added

BIN
contrib/blog-edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
contrib/blog-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -40,6 +40,51 @@ 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://<hôte Org>/files/<Author.UserName>/`. 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 résolveur charge chaque image dans un flux mémoire repositionnable
initialement à la position zéro, nécessaire au décodage du lecteur Markdown.
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/<login>/...` 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

View file

@ -21,6 +21,7 @@
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />

View file

@ -0,0 +1,346 @@
using System.Net;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Headless.XUnit;
using Avalonia.Logging;
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;
using SkiaSharp;
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<Grid>("PostReader")!.IsVisible);
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.False(page.SaveButton.IsVisible);
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
Assert.Equal(vm.ReaderArticle, reader.Markdown);
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
vm.EditPostCommand.Execute(null);
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
Assert.True(page.SaveButton.IsVisible);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
var post = Post();
vm.Posts.Add(post);
vm.FilteredPosts.Add(post);
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
Window? window = null;
try
{
var page = new BlogsPage();
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
Assert.NotNull(engine.HyperlinkCommand);
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
page.DataContext = vm;
var nav = new NavigationPage();
await nav.PushAsync(page);
window = new Window { Width = 800, Height = 700, Content = nav };
window.Show();
Dispatcher.UIThread.RunJobs();
Assert.Empty(vm.ReaderTitle);
Assert.Null(vm.ReaderDateModified);
Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand);
Assert.IsType<BlogImageResolver>(reader.Plugins.PathResolver);
vm.SelectedPost = post;
Dispatcher.UIThread.RunJobs();
Assert.Equal(post.Title, vm.ReaderTitle);
Assert.Equal(post.DateModified, vm.ReaderDateModified);
vm.SelectedPost = null;
page.DataContext = null;
Dispatcher.UIThread.RunJobs();
Assert.Empty(vm.ReaderTitle);
Assert.Null(vm.ReaderDateModified);
Assert.NotNull(engine.HyperlinkCommand);
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding);
}
finally
{
window?.Close();
Logger.Sink = previousSink;
}
}
[AvaloniaFact]
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
{
const string url = "https://org.example.test/files/paul/absent.jpg";
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
var resolver = new BlogImageResolver(client);
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
try
{
await Assert.ThrowsAsync<HttpRequestException>(() => resolver.ResolveImageResource(url));
var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages");
Assert.Contains(url, error.Values);
}
finally
{
Logger.Sink = previousSink;
}
}
private sealed class RecordingLogSink : ILogSink
{
public List<(string Area, object?[] Values)> Entries { get; } = [];
public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning;
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
=> Entries.Add((area, []));
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values)
=> Entries.Add((area, values));
}
[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<MarkdownScrollViewer>("ArticleReader")!;
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().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();
}
}
[Theory]
[InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
[InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
[InlineData("blogs/14/IMG-20260923-WA0001.jpg")]
public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url)
{
using var source = new SKBitmap(32, 24);
source.Erase(SKColors.CornflowerBlue);
using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90);
var jpeg = encoded.ToArray();
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
{
Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg",
request.RequestUri!.AbsoluteUri);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new NonSeekableStream(jpeg))
};
}));
var resolver = new BlogImageResolver(client)
{
AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/"
};
await using var stream = await resolver.ResolveImageResource(url);
Assert.NotNull(stream);
Assert.True(stream.CanSeek);
Assert.Equal(0, stream.Position);
using var decoded = SKBitmap.Decode(stream);
Assert.NotNull(decoded);
Assert.Equal(32, decoded.Width);
Assert.Equal(24, decoded.Height);
}
private sealed class NonSeekableStream(byte[] bytes) : Stream
{
private readonly MemoryStream _inner = new(bytes, writable: false);
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _inner.ReadAsync(buffer, cancellationToken);
public override void Flush() => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
_inner.Dispose();
base.Dispose(disposing);
}
}
[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<VideoPageViewModel>((_, _) => new ContentPage());
app.DataTemplates.Insert(0, template);
try
{
var page = new BlogsPage { DataContext = vm };
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
Dispatcher.UIThread.RunJobs();
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
var link = Assert.IsType<CHyperlink>(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<VideoPageViewModel>(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<VideoPage>(new ViewLocator().Build(vm));
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("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);
}
}

View file

@ -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<InvalidOperationException>(() => 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<HttpRequestException>(() => 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<InvalidOperationException>(() => 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<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}

View file

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

View file

@ -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<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;

View file

@ -44,6 +44,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<MainWindow>();
services.AddTransient<MainView>();
services.AddTransient<BlogsPage>();
services.AddTransient<VideoPage>();
// Models

View file

@ -39,6 +39,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Markdown.Avalonia.Tight" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />

View file

@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Markdown.Avalonia.Utils;
using Avalonia.Logging;
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<string>? CallerAssemblyNames { get; set; }
public async Task<Stream?> 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.");
try
{
// The Markdown decoder needs a seekable stream positioned at the start.
return new MemoryStream(await _client.GetByteArrayAsync(uri), writable: false);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
Logger.TryGet(LogEventLevel.Error, "BlogImages")?.Log(this,
"Unable to load blog image {Url}: {Error}", uri.AbsoluteUri, ex.Message);
throw;
}
}
}

View file

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

View file

@ -42,6 +42,7 @@ public class ViewLocator : IDataTemplate
return data switch
{
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),

View file

@ -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,87 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
public partial ObservableCollection<BlogUploadFile> 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;
public string ReaderTitle => SelectedPost?.Title ?? string.Empty;
public DateTime? ReaderDateModified => SelectedPost?.DateModified;
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 +339,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
await RefreshPostsAsync();
UpdateReader();
});
}
@ -503,12 +585,14 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
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 +607,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 +629,27 @@ 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));
OnPropertyChanged(nameof(ReaderTitle));
OnPropertyChanged(nameof(ReaderDateModified));
}
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
// Save's CanExecute depends on the buffer: the button must

View file

@ -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 { } }
}

View file

@ -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 @@
</Style>
</ContentPage.Styles>
<Grid
RowDefinitions="Auto,Auto,*"
RowDefinitions="Auto,Auto,*,Auto"
Margin="5,5,10,5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
@ -36,23 +35,30 @@
<TextBlock
FontSize="20"
FontWeight="SemiBold"
Text="Blog Posts" />
Text="Billets de blog" />
<TextBox
HorizontalAlignment="Right"
PlaceholderText="Filter by title or author..."
PlaceholderText="Rechercher un titre ou un auteur..."
Text="{Binding SearchText, Mode=TwoWay}" />
</WrapPanel>
<CommandBar x:Name="BlogsCommandBar"
Grid.Row="1"
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
IsVisible="{Binding IsEditing}" Label="Lire" />
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
IsVisible="{Binding !IsEditing}" Label="Modifier" />
<CommandBarButton
x:Name="SaveButton"
IsVisible="{Binding IsEditing}"
Command="{Binding SaveCommand}"
Classes.modified="{Binding IsDraftModified}"
IsEnabled="{Binding !IsBusy}"
@ -61,13 +67,14 @@
<PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarButton
x:Name="AddAttachmentButton"
IsVisible="{Binding IsEditing}"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
@ -77,6 +84,7 @@
</CommandBarButton>
<CommandBarButton
x:Name="ManageAclButton"
IsVisible="{Binding IsEditing}"
Command="{Binding ManageAclAsync}"
Label="ACL">
<CommandBarButton.Icon>
@ -92,6 +100,7 @@
</CommandBarButton.Icon>
</CommandBarButton>
<CommandBarToggleButton
IsVisible="{Binding IsEditing}"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Label="Publié">
@ -99,13 +108,14 @@
<PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
<CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon>
</CommandBarToggleButton>
<CommandBarButton
x:Name="OpenSignatureDevButton"
IsVisible="{Binding IsEditing}"
Command="{Binding OpenSignatureDevAsync}"
Label="[DEV] Signature">
<CommandBarButton.Icon>
@ -173,7 +183,8 @@
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid
<Grid x:Name="PostEditor"
IsVisible="{Binding IsEditing}"
Grid.Row="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
@ -183,7 +194,6 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@ -226,9 +236,34 @@
</StackPanel>
</Border>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
TextWrapping="Wrap" />
<WrapPanel Grid.Row="1" Orientation="Horizontal"
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
<TextBlock Text="{Binding ReaderDateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
Opacity="0.65" />
</WrapPanel>
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
AssetPathRoot="{Binding ReaderBaseUrl}"
Markdown="{Binding ReaderArticle}"
SaveScrollValueWhenContentUpdated="False">
<reader:MarkdownScrollViewer.Plugins>
<reader:MdAvPlugins>
<reader:MdAvPlugins.PathResolver>
<services:BlogImageResolver />
</reader:MdAvPlugins.PathResolver>
</reader:MdAvPlugins>
</reader:MarkdownScrollViewer.Plugins>
</reader:MarkdownScrollViewer>
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
Text="Choisissez un billet dans la liste pour le lire."
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
</Grid>
</Grid>
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
</Grid>
</ContentPage>

View file

@ -4,25 +4,40 @@ using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using CommunityToolkit.Mvvm.Input;
using Markdown.Avalonia;
namespace PostIt.Views.Blogs;
public partial class BlogsPage : ContentPage
{
private static readonly RelayCommand<string?> DisabledLinkCommand =
new(static _ => { }, static _ => false);
public BlogsPage()
{
InitializeComponent();
DataContextChanged += (_, _) => UpdateHyperlinkCommand();
UpdateHyperlinkCommand();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
private void UpdateHyperlinkCommand()
{
if (ArticleReader.Engine is IMarkdownEngine2 engine)
{
if (DataContext is ViewModels.BlogsViewModel vm)
engine.HyperlinkCommand = vm.OpenResourceCommand;
else
engine.HyperlinkCommand = DisabledLinkCommand;
}
}
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();
}
}

View file

@ -0,0 +1,19 @@
<ContentPage
x:Class="PostIt.Views.Blogs.VideoPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:VideoPageViewModel">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
<Border Grid.Row="1" Background="Black">
<vlc:VideoView x:Name="Video" />
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
</StackPanel>
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
</Grid>
</ContentPage>

View file

@ -0,0 +1,108 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using LibVLCSharp.Shared;
using PostIt.ViewModels;
namespace PostIt.Views.Blogs;
public partial class VideoPage : ContentPage
{
private LibVLC? _libVlc;
private MediaPlayer? _player;
private Media? _media;
public VideoPage() => InitializeComponent();
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
ReleasePlayer();
if (DataContext is not VideoPageViewModel vm)
return;
try
{
Core.Initialize();
_libVlc = new LibVLC();
_player = new MediaPlayer(_libVlc);
_player.EncounteredError += Player_EncounteredError;
_media = new Media(_libVlc, vm.Source);
Video.MediaPlayer = _player;
PlayButton.IsEnabled = true;
PlaybackStatus.Text = vm.MimeType;
if (!_player.Play(_media))
PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur.";
}
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
or BadImageFormatException or PlatformNotSupportedException)
{
ReleasePlayer();
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC.";
}
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
{
ReleasePlayer();
base.OnNavigatedFrom(e);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
ReleasePlayer();
base.OnDetachedFromVisualTree(e);
}
private void ReleasePlayer()
{
PlayButton.IsEnabled = false;
Video.MediaPlayer = null;
if (_player is not null)
{
_player.EncounteredError -= Player_EncounteredError;
_player.Stop();
_player.Dispose();
_player = null;
}
_media?.Dispose();
_media = null;
_libVlc?.Dispose();
_libVlc = null;
}
private void Player_EncounteredError(object? sender, EventArgs e)
=> Dispatcher.UIThread.Post(() =>
{
if (ReferenceEquals(sender, _player))
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
});
private void PlayPause_Click(object? sender, RoutedEventArgs e)
{
if (_player is null || _media is null)
return;
if (_player.IsPlaying)
_player.Pause();
else if (!_player.Play())
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
}
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is not VideoPageViewModel vm)
return;
try
{
var topLevel = TopLevel.GetTopLevel(this)
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
}
catch (Exception ex)
{
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
}
}
}

View file

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Identity;
using Yavsc.Models;
using Yavsc.Server.Helpers;
@ -19,14 +20,20 @@ namespace Yavsc.Controllers
private readonly RoleManager<IdentityRole> _roleManager;
private readonly ApplicationDbContext _dbContext;
private readonly SiteSettings _siteSettings;
private readonly ILogger<AdministrationController> _logger;
public AdministrationController(UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
ApplicationDbContext context)
ApplicationDbContext context,
IOptions<SiteSettings> siteSettings,
ILoggerFactory loggerFactory)
{
_userManager = userManager;
_roleManager = roleManager;
this._dbContext = context;
this._siteSettings = siteSettings.Value;
this._logger = loggerFactory.CreateLogger<AdministrationController>();
}
@ -92,6 +99,16 @@ namespace Yavsc.Controllers
}
var user = await _userManager.FindByIdAsync(User.GetUserId());
var email = user.Email;
var isOk = email == _siteSettings.Owner?.EMail || _siteSettings.Admin?.EMail == email;
if (!isOk)
{
if (_siteSettings.Owner==null && _siteSettings.Admin==null)
_logger.LogError("No owner or admin is set in the site settings.");
return Forbid();
}
// check all user groups exist
if (!await EnsureRoleList())
{