Compare commits

..
Author SHA1 Message Date
d24244aae6
Honor the administration setup
All checks were successful
Dotnet build and test / build (push) Successful in 22m1s
2026-10-11 08:31:27 +01:00
2744619249
fix(postit): mount session banner in headless tests
All checks were successful
Dotnet build and test / build (push) Successful in 25m17s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 07:28:17 +01:00
da35ac0a83
do not decide to attach dev tools from the compilation flag
Some checks failed
Dotnet build and test / build (push) Failing after 32m38s
2026-10-11 06:34:53 +01:00
875a020e0d
simpler, better, faster, stronger 2026-10-11 06:27:13 +01:00
eb347e6900
upload test resultes, in case of failure 2026-10-11 06:12:41 +01:00
c34941a4b9
no need for .env to test 2026-10-11 05:59:59 +01:00
afbefae111
make test>/dev/null 2026-10-11 05:43:03 +01:00
29a5b8be8d
run-on ubuntu-26.04, using the default container
All checks were successful
Dotnet build and test / build (push) Successful in 33m13s
2026-10-11 05:11:53 +01:00
d7e274c93a
run-on ubuntu-26.04 2026-10-11 05:08:24 +01:00
bc8ae74fc3
using pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 2026-10-11 04:29:29 +01:00
b5fa7b9072
Github tests
All checks were successful
Dotnet build and test / build (pull_request) Successful in 26m40s
Dotnet build and test / build (push) Successful in 26m24s
2026-10-11 02:26:31 +01:00
4ec169ed71
Improve PostIt draft attachment and save workflow
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 26m22s
Insert attachment links on file addition, resolve provisional links on first save, preserve the editor selection, and highlight unsaved draft changes. Include regression tests and changelog updates.

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:21:02 +01:00
79ba934bce
UseStaticFiles was called twice 2026-10-11 01:18:10 +01:00
2765d3eef8
Fix URL-safe encoding of uploaded filenames
Replace the fragment marker used for unsupported filename characters with an underscore and cover upload metadata and static-file downloads.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:17:26 +01:00
446b595077
renaming of the 3Blog3 setting => UserFilesRoot
All checks were successful
Dotnet build and test / build (pull_request) Successful in 33m37s
2026-10-11 00:16:44 +01:00
f9d95fd98f
Notify performers of API bookings and client-signed estimates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 00:14:33 +01:00
fae754f102
Display notifications on PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:54:55 +01:00
8b4b6a7c53
Add notifications API for PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:23:55 +01:00
7ee0c30500
Fix authenticated resource usage tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:03:19 +01:00
ebdf51f468
fix(postit): restore Markdown editor and preview scrolling
All checks were successful
Forgejo Release / release (push) Successful in 7m38s
Dotnet build and test / build (pull_request) Successful in 8m25s
Constrain the blog page layout, separate list and article scrolling, and remove content height limits and nested editor scrolling. Add six headless regression cases and update the changelog and layout documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 18:28:21 +01:00
cd7351a783
feat(postit): rejoin chat room automatically after reconnect
Also drop unused usings in BlogsViewModel.
2026-10-09 17:55:03 +01:00
95e8f251d2
test(postit): verify published badge and publication state notifications
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:47:01 +01:00
e7721bc3ab
feat(postit): show a live published badge beside blog post titles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:46:32 +01:00
3f73d7a909
co-auteurs
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m0s
2026-10-09 17:14:41 +01:00
67 changed files with 1776 additions and 328 deletions

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

@ -0,0 +1,3 @@
# Conventions du projet
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.

View file

@ -8,8 +8,7 @@ on:
jobs:
production-deploy:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
steps:
# 1. Récupération du code
- name: Check out the repo
@ -25,8 +24,24 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 10.0.x
- name: Workloads restore
run: dotnet workload restore
- name: Test
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
env:
TEST_FAILED: ""
run: make test || export TEST_FAILED=$?
- uses: actions/upload-artifact@v7
with:
name: my-artifact
path: test-reports/**/*
- name: Fail if tests failed
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
run: exit $TEST_FAILED
# 4. Build et Push de l'image de production finale
- name: Build and push production image
uses: docker/build-push-action@v7

View file

@ -1,10 +1,80 @@
# Changelog
## [1.0.8-rc25] - unstable
### Added
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
et transmise par e-mail et SignalR.
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
pour exposer les notifications publiques et ciblées à PostIt sur la
Home page ; les notifications d'administration restent filtrées.
* [PostIt] Affichage des notifications sur la page d'accueil, avec
actualisation, indicateur de chargement et message d'erreur. Les
notifications publiques sont accessibles sans connexion et la liste
est rechargée lors d'un changement de session.
### 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.Tests] Les tests du bandeau de session utilisent le bandeau
réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché.
Les fenêtres sont fermées après chaque test et les quatre boutons
de navigation sont vérifiés.
* [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
l'authentification, ce qui permet de comptabiliser `ApiCalls` et
`BandwidthMb` au lieu d'enregistrer des zéros.
## [1.0.8-rc24] - unstable
### Added
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
### Changed
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
l'espace disponible et défilent séparément ; un séparateur permet
de redimensionner les deux zones.
### Fixed
* [PostIt] Défilement des articles longs en édition et en aperçu :
suppression de la limite de hauteur qui masquait le contenu Markdown
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
couvrent les barres automatiques, le redimensionnement et le
défilement indépendant de la liste.
## [1.0.8-rc23] - unstable
### Added
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
liste, mis à jour lors de la publication ou du retour en brouillon.
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
d'avatar, confirmation e-mail, préférence de courriel mensuel,

View file

@ -1,36 +1,12 @@
# Utilisation de l'image globale pour le build et l'exécution
FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env
WORKDIR /src
# 1. Copie de la solution
COPY *.sln ./
COPY Directory.*.props ./
# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
# Projets associés à PostIt
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# 4. Copie de l'intégralité du code source
COPY . .
RUN dotnet workload restore
# 4. Restauration des dépendances pour tous les projets
RUN dotnet restore
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
COPY . .
# 6. Compilation du serveur Web principal
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly

View file

@ -5,8 +5,6 @@ DESTDIR=/tmp/yavsc
APP_PATH=srv/www/yavsc
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
include .env
all:
dotnet build --nologo

View file

@ -232,7 +232,7 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
* ~/Views/Home/Index.cshtml
* ~/Views/Home/About.cshtml
# 👥 Crédits & Co-autoraClaudt
# 👥 Crédits & Co-auteurs
Ce projet a été développé de manière hybride :
- **Conception, architecture et revue de code :** Paul Schneider

View file

@ -8,7 +8,7 @@
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -30,7 +30,7 @@
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"UserFilesRoot": "files",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",

View file

@ -126,6 +126,20 @@ le DI est construit. Ordre, dans cet ordre :
> à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Défilement de la page des billets
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
dépasser le viewport pour que les barres automatiques s'activent.
Ne pas placer cette grille dans un `StackPanel` vertical ou un
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"`

View file

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

View file

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

View file

@ -0,0 +1,199 @@
using System.Net.Http;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class HomePageNotificationsTests
{
[Fact]
public async Task Refresh_loads_public_notifications_from_the_business_host()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
await vm.RefreshNotificationsAsync();
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
Assert.True(transport.WasAnonymous);
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
Assert.False(vm.IsLoadingNotifications);
Assert.False(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Old"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.False(vm.HasNoNotifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
Assert.False(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
await vm.RefreshNotificationsAsync();
Assert.Null(vm.NotificationsError);
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
[Fact]
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
var oldResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = oldResponse.Task;
var oldLoad = vm.RefreshNotificationsAsync();
Assert.True(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
await vm.RefreshNotificationsAsync();
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
await oldLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.False(vm.IsLoadingNotifications);
}
[AvaloniaFact]
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
var settings = new Settings();
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(settings, store);
try
{
var session = new SessionStatusViewModel { Api = api };
var transport = new NotificationTransport();
var vm = CreateViewModel(transport, session);
session.Refresh();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
vm.Notifications.Add(Note(9, "Private"));
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
session.SetError("Disconnected");
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.True(transport.WasAnonymous);
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
await privateLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
File.Delete(tokenPath);
}
}
[AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{
var transport = new NotificationTransport
{
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
};
var vm = CreateViewModel(transport);
var page = new HomePage { DataContext = vm };
var nav = new NavigationPage();
var window = new Window { Content = nav, Width = 320, Height = 450 };
try
{
window.Show();
await nav.PushAsync(page);
window.UpdateLayout();
Assert.Single(vm.Notifications);
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
Assert.Contains(texts, t => t.Text == "Notification title"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
Assert.Contains(texts, t => t.Text == "Notification body"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
await nav.PushAsync(new ContentPage());
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
window.Close();
}
}
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
SessionStatusViewModel? session = null)
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
private static Notification Note(long id, string title)
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
private sealed class NotificationTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public bool WasAnonymous { get; private set; }
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> ReadAsync<T>(path, true);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;
WasAnonymous = anonymous;
return (T)(object)await Response;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync()
{
Http.Dispose();
return ValueTask.CompletedTask;
}
}
}

View file

@ -0,0 +1,143 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using PostIt.Controls;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class MarkdownEditorScrollTests
{
private static readonly string LongArticle =
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = LongArticle,
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
AssertScrollable(scroller);
var initialViewport = scroller.Viewport.Height;
window.Height = 300;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(scroller.Viewport.Height < initialViewport);
AssertScrollable(scroller);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage();
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;
var posts = page.FindControl<ListBox>("PostsListBox")!;
posts.ItemsSource = Enumerable.Range(1, 50)
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
var navigation = new NavigationPage();
navigation.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Width = 800, Height = 700, Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
AssertScrollable(scroller);
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
Assert.NotNull(editorBottom);
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Short_article_needs_no_vertical_scrollbar(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = "Short article",
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
}
finally
{
CloseWindow(window, editor);
}
}
private static void CloseWindow(Window window, MarkdownEditorControl editor)
{
// The package's shared renderer caches the visual; detach it between headless applications.
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
window.UpdateLayout();
window.Close();
}
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
{
if (!preview)
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
.GetVisualDescendants().OfType<ScrollViewer>().Single();
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
}
private static void AssertScrollable(ScrollViewer scroller)
{
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
scroller.ScrollToEnd();
Assert.True(scroller.Offset.Y > 0);
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
}
}

View file

@ -1,12 +1,132 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views.Blogs;
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()
{
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
var page = new BlogsPage();
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
row.DataContext = post;
var window = new Window { Content = row };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
border => border.Name == "PublishedBadge");
Assert.True(badge.IsVisible);
post.IsPublished = false;
Dispatcher.UIThread.RunJobs();
Assert.False(badge.IsVisible);
post.IsPublished = true;
Dispatcher.UIThread.RunJobs();
Assert.True(badge.IsVisible);
}
finally
{
window.Close();
}
}
[Fact]
public void SearchCommand_filters_posts_by_title_article_or_author()
@ -61,11 +181,22 @@ public class PostItViewModelTests
var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
var changes = new List<string?>();
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished);
Assert.True(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
await viewModel.SetPublishStateAsync(false);
Assert.False(api.LastPublishValue);
Assert.False(viewModel.DraftIsPublished);
Assert.False(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
}
/// <summary>Test fake that always throws if the API is invoked.</summary>

View file

@ -1,6 +1,5 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
@ -23,41 +22,36 @@ namespace PostIt.Tests;
/// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para>
///
/// <para>The session banner's <c>DataContext</c> is not wired in
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// <para>These tests set the DataContext directly on the banner
/// mounted in the window. The production code path is exercised
/// end-to-end by the manual launch, not here.</para>
/// </summary>
public class SessionStatusBannerTests
public class SessionStatusBannerTests : IDisposable
{
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
MainWindow window = new MainWindow();
window.Show();
private MainWindow? _window;
var buttons = window.GetVisualDescendants()
[AvaloniaFact]
public void Banner_renders_four_buttons_in_the_visual_tree()
{
SessionStatusBanner banner = CreateBanner();
var buttons = banner.GetVisualDescendants()
.OfType<Button>()
.ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.True(buttons.Count>=4);
Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
Assert.Contains(buttons, b => b.Content as string == "À propos");
}
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
MainWindow window = new MainWindow();
window.Show();
SessionStatusBanner banner = CreateBanner();
var login = window.GetVisualDescendants()
var login = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
@ -69,12 +63,9 @@ public class SessionStatusBannerTests
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
SessionStatusBanner banner = CreateBanner();
Assert.False((banner.DataContext as SessionStatusViewModel)!
.IsLoggedIn); // VM default
SessionStatusBanner banner = CreateBanner();
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
var logout = banner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
@ -91,22 +82,23 @@ public class SessionStatusBannerTests
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
// Paramètres is the only button with no IsVisible
// binding — always shown. The user's only path to the
// settings page goes through this button.
// Paramètres has no IsVisible binding and is always shown.
Assert.True(settings.IsVisible);
}
private static SessionStatusBanner CreateBanner()
private SessionStatusBanner CreateBanner()
{
MainWindow window = new MainWindow();
window.Show();
var banner = new SessionStatusBanner();
var status = new SessionStatusViewModel();
banner.DataContext = status;
_window = new MainWindow();
_window.Show();
var banner = _window.GetVisualDescendants()
.OfType<SessionStatusBanner>()
.Single();
banner.DataContext = new SessionStatusViewModel();
return banner;
}
public void Dispose() => _window?.Close();
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{

View file

@ -17,6 +17,56 @@ namespace PostIt.Tests;
/// </summary>
public class YavscApiClientTests
{
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
await Assert.ThrowsAsync<HttpRequestException>(() =>
client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
{
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
var store = new TokenStore(tokensPath);
if (hasSession)
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var client = new YavscApiClient(new Settings(), store);
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
Assert.Equal(2, posts.Count);
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
private static int GetFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);

View file

@ -44,10 +44,6 @@ public partial class App : Application, ILiveView, IVMPusherApp
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
#if DEBUG
this.AttachDeveloperTools();
#endif
}
public override void OnFrameworkInitializationCompleted()

View file

@ -7,35 +7,27 @@
x:Name="Root"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="260">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
Padding="0"
MinHeight="260">
<ScrollViewer
BringIntoViewOnFocusChange="False"
Padding="0">
<AvaloniaEdit:TextEditor
x:Name="SourceEditor"
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="0"
Padding="5,5,5,15"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<AvaloniaEdit:TextEditor
x:Name="SourceEditor"
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="0"
Padding="5,5,5,15"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" />
</ScrollViewer>
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" />
</Border>
<Border
@ -43,18 +35,21 @@
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8"
MinHeight="260">
Padding="8">
<ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
<md:MarkdownViewer
x:Name="PreviewViewer"
MarkdownText="{Binding MarkdownText, ElementName=Root}"
ClipToBounds="False" />
HorizontalContentAlignment="Stretch"
VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
</ScrollViewer>
</Border>
</Grid>

View file

@ -38,8 +38,6 @@ public partial class MarkdownEditorControl : UserControl
{
InitializeComponent();
PropertyChanged += OnPropertyChanged;
SizeChanged += OnSizeChanged;
UpdateEditorLimits();
}
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@ -62,27 +60,4 @@ public partial class MarkdownEditorControl : UserControl
}
}
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
UpdateEditorLimits();
}
private void UpdateEditorLimits()
{
var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260;
visibleHeight = Math.Max(visibleHeight, 80);
if (SourceEditor is not null)
{
SourceEditor.MaxHeight = visibleHeight;
SourceEditor.Height = double.NaN;
}
if (PreviewViewer is not null)
{
PreviewViewer.MaxHeight = visibleHeight;
PreviewViewer.Height = double.NaN;
}
}
}

View file

@ -36,6 +36,7 @@ public static class ServiceCollectionHelpers
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
@ -93,6 +94,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);

View file

@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Options.Set(AnonymousRequest, true);
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{
@ -614,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_owner._tokens is not null)
if (_owner._tokens is not null
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken);

View file

@ -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,9 +11,8 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
using Yavsc.Server.Helpers;
using AvaloniaEdit.Document;
using PostIt.Models;
namespace PostIt.ViewModels;
@ -90,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.
@ -109,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; }
@ -158,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
@ -176,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
@ -196,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)
{
SelectedPost = created;
_preserveDraft = true;
try
{
SelectedPost = created;
}
finally
{
_preserveDraft = false;
}
if (TryAppendAttachmentLinks(created, attachments))
if (ResolvePendingAttachmentLinks(created))
{
var linkUpdate = new BlogPostDto
{
@ -213,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,
@ -234,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();
@ -431,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.");
@ -442,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
@ -502,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
@ -515,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();
}
@ -523,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()
@ -536,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()
@ -554,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;
}
}
@ -596,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;
var article = DraftArticle ?? string.Empty;
var links = new List<string>();
foreach (var attachment in attachments)
if (e.NewItems is not null)
{
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);
foreach (BlogUploadFile attachment in e.NewItems)
{
var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
string fileUrl;
if (SelectedPost is { Id: > 0 } post)
fileUrl = AttachmentUrl(post, storedName);
else
{
fileUrl = $"postit-attachment:{Guid.NewGuid():N}";
_pendingAttachmentLinks.Add(fileUrl, storedName);
}
var article = DraftArticle ?? string.Empty;
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
if (!article.Contains(markdownLine, StringComparison.Ordinal))
{
var prefix = article.Length == 0 ? ""
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
DraftArticle = article + prefix + markdownLine;
}
}
}
UpdateDraftModified();
}
if (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)
@ -638,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}";

View file

@ -246,7 +246,7 @@ namespace PostIt.ViewModels.Chat
});
return Task.CompletedTask;
};
_connection.Reconnected += _ =>
_connection.Reconnected += async _ =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
@ -254,7 +254,11 @@ namespace PostIt.ViewModels.Chat
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
});
return Task.CompletedTask;
// La reconnexion crée une nouvelle ConnectionId côté
// serveur : l'appartenance aux salons est perdue, il
// faut rejoindre à nouveau le salon courant.
if (!string.IsNullOrWhiteSpace(CurrentRoom))
await JoinRoomAsync();
};
_connection.Closed += error =>
{

View file

@ -1,11 +1,16 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase
@ -13,6 +18,26 @@ public class HomePageViewModel : ViewModelBase
public YavscApiClient Api { get; }
public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
public ObservableCollection<Notification> Notifications { get; } = new();
private readonly NotificationsApiClient? _notificationsClient;
private int _notificationsLoadVersion;
private bool _isLoadingNotifications;
private string? _notificationsError;
private bool _notificationsLoaded;
public bool IsLoadingNotifications
{
get => _isLoadingNotifications;
private set => SetProperty(ref _isLoadingNotifications, value);
}
public string? NotificationsError
{
get => _notificationsError;
private set => SetProperty(ref _notificationsError, value);
}
public bool HasNoNotifications => _notificationsLoaded && Notifications.Count == 0;
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
@ -24,11 +49,16 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus,
NotificationsApiClient notificationsClient)
{
Api = api;
Settings = settings;
SessionStatus = sessionStatus;
_notificationsClient = notificationsClient;
SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -55,6 +85,59 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public async Task RefreshNotificationsAsync()
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
var version = ++_notificationsLoadVersion;
Notifications.Clear();
NotificationsError = null;
_notificationsLoaded = false;
OnPropertyChanged(nameof(HasNoNotifications));
IsLoadingNotifications = true;
try
{
var notifications = SessionStatus.IsLoggedIn
? await _notificationsClient.GetCurrentAsync()
: await _notificationsClient.GetPublicAsync();
if (version != _notificationsLoadVersion)
return;
if (notifications is null)
throw new InvalidOperationException("The notifications response is empty.");
foreach (var notification in notifications)
Notifications.Add(notification);
_notificationsLoaded = true;
}
catch (Exception ex)
{
if (version == _notificationsLoadVersion)
NotificationsError = $"Impossible de charger les notifications : {ex.Message}";
}
finally
{
if (version == _notificationsLoadVersion)
{
IsLoadingNotifications = false;
OnPropertyChanged(nameof(HasNoNotifications));
}
}
}
private void OnSessionStatusChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(SessionStatusViewModel.IsLoggedIn) || _notificationsClient is null)
return;
// Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync();
else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync());
}
private async Task OpenActivitiesAsync()
{
@ -228,7 +311,7 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
{
}

View file

@ -17,14 +17,18 @@
<Design.DataContext>
<vm:BlogsViewModel />
</Design.DataContext>
<ScrollViewer
<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"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
BringIntoViewOnFocusChange="False">
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
VerticalAlignment="Stretch">
<WrapPanel x:Name="BlogsHeaderPanel"
Margin="5"
@ -39,6 +43,7 @@
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">
@ -48,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}" />
@ -62,6 +69,7 @@
<CommandBarButton
x:Name="AddAttachmentButton"
Click="AddAttachment_Click"
IsEnabled="{Binding !IsBusy}"
Label="Ajouter fichiers">
<CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" />
@ -107,26 +115,45 @@
</CommandBar.PrimaryCommands>
</CommandBar>
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid" MinHeight="500">
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="4" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
<Border
Grid.Row="1"
Grid.Row="0"
Padding="8"
BorderBrush="Gray"
BorderThickness="1">
<ListBox
x:Name="PostsListBox"
IsEnabled="{Binding !IsBusy}"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding Title}" />
<Border
x:Name="PublishedBadge"
VerticalAlignment="Center"
Padding="6,2"
Background="#F2F7F2"
BorderBrush="#2F6D2F"
BorderThickness="1"
CornerRadius="6"
IsVisible="{Binding IsPublished}">
<TextBlock
FontSize="10"
FontWeight="SemiBold"
Foreground="#2F6D2F"
Text="Publié" />
</Border>
</StackPanel>
<TextBlock
FontSize="10"
Foreground="Gray"
@ -141,6 +168,11 @@
</ListBox>
</Border>
<GridSplitter
Grid.Row="1"
HorizontalAlignment="Stretch"
ResizeDirection="Rows" />
<Grid
Grid.Row="2"
HorizontalAlignment="Stretch"
@ -151,6 +183,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@ -197,6 +230,5 @@
</Grid>
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</ContentPage>

View file

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

View file

@ -2,20 +2,50 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:lang="using:Yavsc.Lang"
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
x:Class="PostIt.Views.HomePage"
x:DataType="vm:HomePageViewModel"
Header="Home">
<Design.DataContext>
<vm:HomePageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
HorizontalAlignment="Stretch">
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
FontSize="22"
FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Notifications" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Actualiser"
Command="{Binding RefreshNotificationsCommand}"
IsEnabled="{Binding !IsLoadingNotifications}"/>
</Grid>
<ProgressBar IsIndeterminate="True" Height="4"
IsVisible="{Binding IsLoadingNotifications}"/>
<TextBlock Text="{Binding NotificationsError}" TextWrapping="Wrap"
Foreground="{DynamicResource SystemControlErrorTextForegroundBrush}"/>
<TextBlock Text="Aucune notification pour le moment."
IsVisible="{Binding HasNoNotifications}" TextWrapping="Wrap"/>
<ItemsControl x:Name="NotificationsList" ItemsSource="{Binding Notifications}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="messaging:Notification">
<Border Padding="12" Margin="0,0,0,8" CornerRadius="6"
BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6">
<TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Les Salons de discussion"
Command="{Binding OpenChat}"
HorizontalAlignment="Center"
@ -61,4 +91,5 @@
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -1,4 +1,5 @@
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
@ -8,4 +9,12 @@ public partial class HomePage : ContentPage
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
&& vm.RefreshNotificationsCommand.CanExecute(null))
_ = vm.RefreshNotificationsAsync();
}
}

View file

@ -1,12 +1,16 @@
#nullable enable annotations
using Yavsc.Abstract.Identity.Security;
using System.ComponentModel;
using System.Text.Json.Serialization;
namespace Yavsc.Blogspot;
public class BlogPostDto : IBlogPost
public class BlogPostDto : IBlogPost, INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private bool _isPublished;
public string AuthorId { get; set; }
public BlogPostAuthorDto? Author { get; set; }
@ -30,7 +34,18 @@ public class BlogPostDto : IBlogPost
/// follow-up request. Toggled via
/// <c>PUT /api/BlogApi/{id}/publish</c>.
/// </summary>
public bool IsPublished { get; set; }
public bool IsPublished
{
get => _isPublished;
set
{
if (_isPublished == value)
return;
_isPublished = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsPublished)));
}
}
public virtual bool AuthorizeCircle(long circleId)
{

View file

@ -33,6 +33,8 @@ namespace Yavsc
public const string ResourceUsagePath = "resource-usage";
public const string ResourceUsageCurrentUserRoute = "me";
public const string ResourceUsageAdminRoute = "admin";
public const string NotificationsPath = "notifications";
public const string NotificationsCurrentUserRoute = "me";
public const string ResourceUsageDefaultCurrency = "EUR";
public const string ResourceUsageCurrentUserDisplayName = "current-user";
public const string ResourceUsageAdminDisplayName = "administrator";

View file

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

View file

@ -42,6 +42,10 @@ public interface IYavscApiClient : IAsyncDisposable
/// </summary>
HttpClient Http { get; }
/// <summary>Read a public JSON endpoint without attaching session credentials.</summary>
Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> throw new NotSupportedException("Anonymous requests are not supported by this transport.");
/// <summary>Call a JSON endpoint with a typed return value.</summary>
/// <param name="method">HTTP verb.</param>
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>

View file

@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Models.Messaging;
namespace Yavsc.Api.Client;
/// <summary>
/// Thin HTTP client for the notifications exposed to PostIt on the home page.
/// </summary>
public sealed class NotificationsApiClient
{
private const string PathPrefix = Constants.NotificationsPath;
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public NotificationsApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
public Task<List<Notification>> GetCurrentAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<Notification>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"),
ct: ct);
}
public Task<List<Notification>> GetAllAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<Notification>>(
HttpMethod.Get,
Absolute(PathPrefix),
ct: ct);
}
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Models;
using Yavsc.Server.Helpers;
namespace Yavsc.ApiControllers.Administration;
[Produces("application/json")]
[Route(Constants.APIPrefix + "/" + Constants.NotificationsPath)]
public sealed class NotificationsApiController : Controller
{
private readonly ApplicationDbContext _dbContext;
public NotificationsApiController(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpGet]
[HttpGet(Constants.NotificationsCurrentUserRoute)]
[AllowAnonymous]
public async Task<ActionResult<List<Notification>>> GetCurrentUserNotifications()
{
var currentUserId = User.GetUserId();
var isAdmin = User.IsInRole(Constants.AdminGroupName);
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
? new HashSet<long>()
: await _dbContext.DismissClicked
.AsNoTracking()
.Where(d => d.UserId == currentUserId)
.Select(d => d.NotificationId)
.ToHashSetAsync();
var notifications = await _dbContext.Notification
.AsNoTracking()
.Where(n => !dismissedIds.Contains(n.Id))
.Where(n =>
string.IsNullOrWhiteSpace(n.Target)
|| (n.Target != null && n.Target.StartsWith("pub/"))
|| (!string.IsNullOrWhiteSpace(currentUserId) && n.Target == $"user/{currentUserId}")
|| (isAdmin && n.Target == "administration"))
.OrderByDescending(n => n.Id)
.ToListAsync();
return Ok(notifications);
}
}

View file

@ -322,6 +322,8 @@ namespace Yavsc.ApiControllers
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
estimate.ClientValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
}
@ -382,12 +384,9 @@ namespace Yavsc.ApiControllers
.FirstOrDefaultAsync(e => e.Id == id, token);
if (estimate is null) return NotFound(new { Error = "estimate not found" });
// The signer is identified by userId in the body, not
// by the bearer token, because the OAuth scope we
// carry is for the API client (PostIt), not the end
// user. We trust the body's userId to match either
// Owner or Client, and reject everything else.
var userId = body.SignerUserId;
if (userId != User.GetUserId())
return Forbid();
if (userId != estimate.OwnerId && userId != estimate.ClientId)
return Forbid();
@ -414,6 +413,9 @@ namespace Yavsc.ApiControllers
var signature = await EstimateSignaturePersister.StageAsync(
dbContext, id, type, userId, payload, token);
if (type == SignatureType.Client)
estimate.ClientValidationDate = DateTime.UtcNow;
try
{
await dbContext.SaveChangesAsync(token);
@ -424,6 +426,12 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
if (type == SignatureType.Client)
{
var signedEvent = new ClientEstimateSignedEvent(estimate, estimate.Client.UserName);
await _GCMSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
}
var location = Url.Action(nameof(Sign), new { id })
?? $"/api/bill/estimate/{id}/sign";
return Created(location, new
@ -441,9 +449,8 @@ namespace Yavsc.ApiControllers
/// <summary>
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
/// field disambiguates which side of the estimate signed
/// because the bearer token belongs to the PostIt OAuth
/// client, not the end user.
/// field must match the authenticated caller and identifies
/// which party of the estimate signed.
/// </summary>
public class SignatureSubmission
{

View file

@ -1,3 +1,5 @@
#nullable enable annotations
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -10,6 +12,7 @@ using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Messaging;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
@ -26,6 +29,7 @@ namespace Yavsc.ApiControllers
private readonly ILogger logger;
private readonly SiteSettings siteSettings;
private readonly IRazorViewEngine viewEngine;
private readonly IYavscMessageSender messageSender;
// The view-rendering dependencies (siteSettings, viewEngine) are
// optional: only EstimateTex/EstimatePdf use them, and those run in
@ -45,11 +49,13 @@ namespace Yavsc.ApiControllers
public FrontOfficeApiController(
ApplicationDbContext context,
ILoggerFactory loggerFactory,
IYavscMessageSender messageSender,
IOptions<SiteSettings> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
{
dbContext = context;
this.messageSender = messageSender;
this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
@ -233,6 +239,7 @@ namespace Yavsc.ApiControllers
if (query is null) return BadRequest(new { Error = "query not found" });
Signature? signature = null;
ClientEstimateSignedEvent? signedEvent = null;
// actingRole is the role the caller is signing / accepting
// as. It defaults to the role inferred from the caller's
// identity, but when a signature is submitted the caller
@ -299,7 +306,11 @@ namespace Yavsc.ApiControllers
if (actingRole == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
{
estimate.ClientValidationDate = DateTime.UtcNow;
var signer = await dbContext.Users.SingleAsync(u => u.Id == uid, token);
signedEvent = new ClientEstimateSignedEvent(estimate, signer.UserName);
}
}
// The party-specific status records who accepted: the
@ -323,6 +334,9 @@ namespace Yavsc.ApiControllers
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
if (signedEvent is not null)
await messageSender.NotifyAsync(new[] { signedEvent.PerformerId }, signedEvent);
return Ok(new
{
queryId,

View file

@ -10,6 +10,8 @@ using Yavsc.Models.Billing;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.Helpers;
using Yavsc.Services;
namespace Yavsc.Controllers;
@ -19,10 +21,12 @@ namespace Yavsc.Controllers;
public class RdvQueryApiController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IYavscMessageSender _messageSender;
public RdvQueryApiController(ApplicationDbContext context)
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
{
_context = context;
_messageSender = messageSender;
}
[HttpGet]
@ -93,6 +97,16 @@ public class RdvQueryApiController : Controller
return BadRequest(new { Error = "location is required" });
}
var performer = await _context.Performers
.AsNoTracking()
.SingleOrDefaultAsync(p => p.PerformerId == query.PerformerId, cancellationToken);
if (performer is null)
return BadRequest(new { Error = "performer not found" });
var client = await _context.Users.SingleOrDefaultAsync(u => u.Id == uid, cancellationToken);
if (client is null)
return Unauthorized();
query.Client = client;
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
if (resolvedLocation is null)
{
@ -124,6 +138,9 @@ public class RdvQueryApiController : Controller
throw;
}
if (performer.AcceptNotifications && performer.AcceptPublicContact)
await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand"));
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
}

View file

@ -134,11 +134,11 @@ internal class Program
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
app
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
.UseCors("default");
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
app.MapDefaultControllerRoute();

View file

@ -1,8 +0,0 @@
namespace Yavsc.Blogs;
public static class BlogConstants
{
public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator";
public const string UserRole = "User";
}

View file

@ -200,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
if (string.IsNullOrEmpty(ownerUserName))
return NotFound(new { error = "owner not found" });
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
var fi = new FileInfo(physicalPath);
if (!fi.Exists) return NotFound(new { error = "file not on disk" });

View file

@ -9,7 +9,6 @@ using Yavsc.Models;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Yavsc.Blogs;
internal class Program
@ -85,9 +84,9 @@ internal class Program
})
.Configure<SiteSettings>(settings =>
{
if (settings.Blog == null)
if (settings.UserFilesRoot == null)
{
settings.Blog = "blog";
settings.UserFilesRoot = "blog";
}
});
@ -98,11 +97,11 @@ internal class Program
{
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
app
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
.UseCors("default")
;
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")

View file

@ -9,7 +9,7 @@
"https://localhost:5005",
"https://api.pschneider.fr"
],
"Blog": "blog"
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"

View file

@ -421,9 +421,9 @@ namespace Yavsc.Controllers
{
// Renames the blog files
var userdirinfo = new DirectoryInfo(
Path.Combine(_siteSettings.Blog,
Path.Combine(_siteSettings.UserFilesRoot,
oldUserName));
var newdir = Path.Combine(_siteSettings.Blog,
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
model.UserName);
if (userdirinfo.Exists)
userdirinfo.MoveTo(newdir);

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())
{

View file

@ -1046,7 +1046,6 @@ public static class HostingExtensions
app.UseSwagger();
app.UseSwaggerUI();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseIdentityServer();
@ -1267,7 +1266,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
{
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
}
if (settings.Blog == null)
if (settings.UserFilesRoot == null)
{
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
}
@ -1284,7 +1283,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
}
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
var userFilesDirInfo = new DirectoryInfo(settings.UserFilesRoot);
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();

View file

@ -11,28 +11,6 @@ namespace Yavsc.Helpers
public static class EventHelpers
{
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
{
var yaev = new RdvQueryEvent(subtopic)
{
Sender = query.ClientId,
Reason = query.Reason,
Client = new ClientProviderInfo {
UserName = query.Client.UserName ,
UserId = query.ClientId,
Avatar = query.Client.Avatar } ,
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
ActivityCode = query.ActivityCode,
BillingCode = BillingCodes.Rdv
};
return yaev;
}
public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query,
IStringLocalizer<HairCutQuery> SR)
{

View file

@ -23,7 +23,7 @@ public class Startup
ResourcesHelpers.GlobalLocalizer = localizer;
Config.SmtpSetup = smtpSettings.Value;
Config.Authority = siteSettings.Value.Authority;
string blogsDir = siteSettings.Value.Blog ?? throw new Exception("blogsDir is not set.");
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");

View file

@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
{
string uid = ViewContext.HttpContext.User.GetUserId();
return View(new UserDirectoryInfo(
siteSettings.Blog,
siteSettings.UserFilesRoot,
uid, dirname));
}
}

View file

@ -31,7 +31,7 @@
"Avatars": "avatars",
"Quota": 200000000,
"Bills": "bills",
"Blog": "blog",
"UserFilesRoot": "files",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"

View file

@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
this ClaimsPrincipal user,
string subpath, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
@ -67,7 +67,7 @@ namespace Yavsc.Server.Helpers
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileName));
if (!fi.Exists) return;
// Capture the length BEFORE deleting: FileInfo.Length throws
@ -80,7 +80,7 @@ namespace Yavsc.Server.Helpers
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(dirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
@ -110,7 +110,7 @@ namespace Yavsc.Server.Helpers
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
if (string.IsNullOrEmpty(fromDirName))
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
@ -136,7 +136,7 @@ namespace Yavsc.Server.Helpers
}
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
string dest;
@ -151,7 +151,7 @@ namespace Yavsc.Server.Helpers
}
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.UserName);
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
var fo = new FileInfo(Path.Combine(root, fileNameDest));
@ -318,7 +318,7 @@ namespace Yavsc.Server.Helpers
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
{
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
return di;
}

View file

@ -0,0 +1,29 @@
using Yavsc.Abstract.Identity;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Messaging;
using Yavsc.Models.Workflow;
namespace Yavsc.Helpers;
public static class RdvQueryEventHelpers
{
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
=> new(subtopic)
{
Sender = query.ClientId,
Reason = query.Reason,
Client = new ClientProviderInfo
{
UserName = query.Client.UserName,
UserId = query.ClientId,
Avatar = query.Client.Avatar
},
Previsional = query.Provisional,
EventDate = query.EventDate,
Location = query.Location,
Id = query.Id,
ActivityCode = query.ActivityCode,
BillingCode = BillingCodes.Rdv
};
}

View file

@ -0,0 +1,23 @@
using Yavsc.Interfaces.Workflow;
using Yavsc.Models.Billing;
namespace Yavsc.Models.Messaging;
public sealed class ClientEstimateSignedEvent : IEvent
{
public ClientEstimateSignedEvent(Estimate estimate, string sender)
{
EstimateId = estimate.Id;
EstimateTitle = estimate.Title;
PerformerId = estimate.OwnerId;
Sender = sender;
}
public long EstimateId { get; }
public string EstimateTitle { get; }
public string PerformerId { get; }
public string Topic => "ClientEstimateSigned";
public string Sender { get; set; }
public string Title => "Devis signé par le client";
public string CreateBody() => $"{Sender} a signé le devis n°{EstimateId} : {EstimateTitle}.";
}

View file

@ -36,7 +36,7 @@ public class BlogSpotService
_context = context;
this.fileSystemAuthManager = fileSystemAuthManager;
this.siteSettings = siteSettings.Value;
if (siteSettings.Value.Blog == null)
if (siteSettings.Value.UserFilesRoot == null)
{
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
}
@ -56,7 +56,7 @@ public class BlogSpotService
{
string blogFilesSubdir = $"blogs/{postId}";
string destDir = Path.Combine(
siteSettings.Blog,
siteSettings.UserFilesRoot,
user.UserName,
blogFilesSubdir);
var di = new DirectoryInfo(destDir);

View file

@ -9,6 +9,7 @@ using Yavsc.Models.Google.Messaging;
using Yavsc.Models.Haircut;
using Yavsc.Models.Messaging;
using Yavsc.Server.Hubs;
using Yavsc.Abstract.Models.Messaging;
namespace Yavsc.Services
{
@ -30,7 +31,7 @@ namespace Yavsc.Services
IHubContext<ChatHub> hubContext
)
{
_logger = loggerFactory.CreateLogger<MailSender>();
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
_emailSender = emailSender;
siteSettings = sitesOptions?.Value;
this.hubContext = hubContext;
@ -70,34 +71,51 @@ namespace Yavsc.Services
};
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
results.Add(result);
if (user == null)
{
response.failure++;
result.error = "no such user.";
continue;
}
if (!user.EmailConfirmed)
{
response.failure++;
result.error = "user has not confirmed his email address.";
continue;
}
if (user.Email == null)
{
response.failure++;
result.error = "user has no legacy email address.";
continue;
}
var body = ev.CreateBody();
var subject = ev is ClientEstimateSignedEvent signed
? signed.Title
: $"{ev.Sender} (un client) vous demande un rendez-vous";
if (ev is RdvQueryEvent || ev is ClientEstimateSignedEvent)
{
_dbContext.Notification.Add(new Notification
{
title = subject.Length > 1024 ? subject[..1024] : subject,
body = body.Length > 512 ? body[..512] : body,
Target = $"user/{userId}",
tag = ev switch
{
ClientEstimateSignedEvent estimateEvent => $"estimate/{estimateEvent.EstimateId}/client-signed",
RdvQueryEvent rdvEvent => $"rdv/{rdvEvent.Id}",
_ => throw new InvalidOperationException("Unsupported persistent notification event.")
},
click_action = "Fermer"
});
await _dbContext.SaveChangesAsync(ev.Sender);
}
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
{
response.failure++;
result.error = !user.EmailConfirmed
? "user has not confirmed his email address."
: "user has no legacy email address.";
_logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error);
}
else
{
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
$"{ev.Sender} (un client) vous demande un rendez-vous",
subject,
body + Environment.NewLine);
response.success++;
}
var cxIds = _cxManager.GetConnexionIds(user.UserName);
if (cxIds == null)
@ -125,7 +143,6 @@ namespace Yavsc.Services
response.success++;
}
results.Add(result);
}
response.results = results.ToArray();
return response;

View file

@ -49,7 +49,7 @@ namespace Yavsc
public string DataDir { get; set; }
public string Avatars { get; set; }
public long Quota { get; set; }
public string Blog { get; set; }
public string UserFilesRoot { get; set; }
public string Bills { get; set; }
public string GitRepository { get; set; }

View file

@ -0,0 +1,78 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.ApiControllers;
using Yavsc.Models;
using Yavsc.Models.Billing;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class BillingSignatureNotificationTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
[Theory]
[InlineData("bob", "bob", true)]
[InlineData("alice", "alice", false)]
[InlineData("alice", "bob", false)]
public async Task Json_signature_notifies_only_when_the_authenticated_client_signs(
string caller, string signer, bool notifies)
{
fixture.ResetAndSeedActivityGraph();
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
Title = "Devis notification", Description = "Devis de test",
AttachedFiles = new List<string>(), AttachedGraphics = new List<string>()
};
db.Estimates.Add(estimate);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
var controller = ActivatorUtilities.CreateInstance<BillingController>(scope.ServiceProvider);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", caller) }, "test"))
}
};
controller.Url = new SignatureUrlHelper();
var result = await controller.Sign(estimate.Id, new SignatureSubmission
{
SignerUserId = signer, CoordinateMax = 10000, Strokes = new[] { 1, 5000, 5000 }
}, TestContext.Current.CancellationToken);
if (caller != signer)
{
Assert.IsType<ForbidResult>(result);
Assert.Empty(db.Signatures);
}
else
Assert.IsType<CreatedResult>(result);
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
if (notifies)
{
Assert.Equal("user/alice", Assert.Single(db.Notification).Target);
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
Assert.NotEqual(default, estimate.ClientValidationDate);
}
else
{
Assert.Empty(db.Notification);
Assert.Empty(delivery.Emails);
}
}
private sealed class SignatureUrlHelper : IUrlHelper
{
public ActionContext ActionContext => throw new NotSupportedException();
public string? Action(UrlActionContext actionContext) => "/api/v1/bill/estimate/sign";
public string? Content(string? contentPath) => throw new NotSupportedException();
public bool IsLocalUrl(string? url) => throw new NotSupportedException();
public string? Link(string? routeName, object? values) => throw new NotSupportedException();
public string? RouteUrl(UrlRouteContext routeContext) => throw new NotSupportedException();
}
}

View file

@ -16,6 +16,9 @@ using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Services;
using Yavsc.Tests.Shared;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Interface;
using Yavsc.Server.Hubs;
namespace Yavsc.Api.Test.Fixtures;
@ -64,8 +67,13 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.AddLocalization();
builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
builder.Services.AddSingleton<RecordingMessageDelivery>();
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdministratorOnly", policy =>
@ -204,23 +212,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
return builder.ToString();
}
private sealed class NoopMessageSender : IYavscMessageSender
{
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
=> Task.FromResult(new MessageWithPayloadResponse());
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
=> Task.FromResult(new MessageWithPayloadResponse());
}
public void ResetAndSeedActivityGraph()
{
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -334,6 +328,8 @@ public sealed class ApiWebServerFixture : WebHostFixture
// CommandLine reference NominativeServiceCommand (CommandId) and
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
db.DismissClicked.RemoveRange(db.DismissClicked);
db.Notification.RemoveRange(db.Notification);
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());

View file

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Yavsc.Interface;
using Yavsc.Server.Hubs;
namespace Yavsc.Api.Test.Fixtures;
public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<ChatHub>, IHubClients
{
public record Email(string Recipient, string Subject, string Body);
public record Push(string ConnectionId, string Method, object?[] Arguments);
public ConcurrentQueue<Email> Emails { get; } = new();
public ConcurrentQueue<Push> Pushes { get; } = new();
IHubClients IHubContext<ChatHub>.Clients => this;
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
public IClientProxy Group(string groupName) => throw new NotSupportedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
public IClientProxy User(string userId) => throw new NotSupportedException();
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new NotSupportedException();
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
{
Emails.Enqueue(new Email(email, subject, htmlMessage));
return Task.FromResult(Guid.NewGuid().ToString());
}
public void Clear()
{
Emails.Clear();
Pushes.Clear();
}
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
return Task.CompletedTask;
}
}
}

View file

@ -10,6 +10,7 @@ using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
using Yavsc.Services;
namespace Yavsc.Api.Test;
@ -138,6 +139,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
// Default caller is "alice" (the provider) → ProAccepted.
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
@ -165,6 +168,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Pro, signature.Type);
Assert.Equal("alice", signature.SignerId);
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
@ -192,6 +197,48 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("bob", signature.SignerId);
var notification = Assert.Single(assertDb.Notification);
Assert.Equal("user/alice", notification.Target);
Assert.Equal($"estimate/{estimateId}/client-signed", notification.tag);
Assert.Contains("bob", notification.body);
Assert.Contains("Devis prestation", notification.body);
var email = Assert.Single(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
Assert.Equal("alice@example.test", email.Recipient);
Assert.Equal("Devis signé par le client", email.Subject);
}
[Fact]
public async Task Client_signature_pushes_to_the_performer_even_without_confirmed_email()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Users.Single(u => u.Id == "alice").EmailConfirmed = false;
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
manager.OnConnected("signed-alice", "alice", false);
try
{
using var http = NewClient("bob");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
Assert.Empty(delivery.Emails);
var push = Assert.Single(delivery.Pushes);
Assert.Equal("signed-alice", push.ConnectionId);
Assert.Equal("ClientEstimateSigned", push.Arguments[0]);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal($"estimate/{estimateId}/client-signed", Assert.Single(db.Notification).tag);
}
finally
{
manager.OnDisconnected("signed-alice");
}
}
[Fact]

View file

@ -5,6 +5,9 @@ using Yavsc;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Services;
namespace Yavsc.Api.Test;
@ -34,6 +37,72 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
return http;
}
[Fact]
public async Task PostQuery_notifies_performer_by_database_email_and_signalr()
{
_fixture.ResetAndSeedRdvQueryGraph();
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
manager.OnConnected("rdv-alice", "alice", false);
try
{
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
{
ActivityCode = "dev", PerformerId = "alice", ClientId = "spoofed",
Consent = true, EventDate = DateTime.UtcNow.AddDays(1),
Location = new { Address = "Notification test", Latitude = 48.0, Longitude = 2.0 },
Reason = "Demande notifiée", Status = QueryStatus.Inserted
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var query = await response.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notification = Assert.Single(db.Notification);
Assert.Equal("user/alice", notification.Target);
Assert.Equal($"rdv/{query!.Id}", notification.tag);
Assert.Contains("bob", notification.title);
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
var push = Assert.Single(delivery.Pushes);
Assert.Equal("rdv-alice", push.ConnectionId);
Assert.Equal("push", push.Method);
Assert.Equal("/topic/RdvQuery", push.Arguments[0]);
}
finally
{
manager.OnDisconnected("rdv-alice");
}
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
public async Task PostQuery_respects_performer_contact_preferences(bool acceptsNotifications, bool acceptsPublicContact)
{
_fixture.ResetAndSeedRdvQueryGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var performer = db.Performers.Single(p => p.PerformerId == "alice");
performer.AcceptNotifications = acceptsNotifications;
performer.AcceptPublicContact = acceptsPublicContact;
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
{
ActivityCode = "dev", PerformerId = "alice", Consent = true,
EventDate = DateTime.UtcNow.AddDays(1),
Location = new { Address = "Contact refused", Latitude = 48.0, Longitude = 2.0 },
Reason = "Demande", Status = QueryStatus.Inserted
}, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(assertDb.Notification);
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
}
[Fact]
public async Task Billing_rdv_route_supports_crud()
{

View file

@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Server.Helpers;
@ -98,6 +99,66 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
Assert.True(record.BandwidthMb >= 3.5m);
}
[Fact]
public async Task GetCurrentUserUsage_records_telemetry_values_from_authenticated_requests()
{
using var http = NewClient(_fixture, "telemetry-user");
var response = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
var record = await db.ResourceUsageRecords
.SingleAsync(r => r.UserId == "telemetry-user");
Assert.True(record.ApiCalls >= 1m);
Assert.True(record.BandwidthMb > 0m);
}
[Fact]
public async Task GetCurrentUserNotifications_returns_public_and_targeted_notifications()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
db.Notification.RemoveRange(db.Notification);
await db.Notification.AddRangeAsync(
new Notification
{
title = "Public",
body = "Seen by everyone",
click_action = "/public",
Target = null,
},
new Notification
{
title = "User",
body = "Private to current user",
click_action = "/profile",
Target = "user/telemetry-user",
},
new Notification
{
title = "Admin",
body = "Restricted to administrators",
click_action = "/admin",
Target = "administration",
});
await db.SaveChangesAsync();
using var http = NewClient(_fixture, "telemetry-user");
var response = await http.GetAsync("/api/v1/notifications/me", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var notifications = await response.Content.ReadFromJsonAsync<List<Notification>>(TestContext.Current.CancellationToken);
Assert.NotNull(notifications);
Assert.Contains(notifications!, n => n.title == "Public");
Assert.Contains(notifications!, n => n.title == "User");
Assert.DoesNotContain(notifications!, n => n.title == "Admin");
}
[Fact]
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
{

View file

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

View file

@ -0,0 +1,76 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.FileProviders;
using Yavsc.Models;
using Yavsc.Server.Helpers;
namespace Yavsc.Blogs.Tests;
public sealed class FilterFileNameTests
{
[Theory]
[InlineData("note.txt", "note.txt")]
[InlineData("ABC-123=_~.PDF", "ABC-123=_~.PDF")]
[InlineData("Troisi\u00e8me.PDF", "Troisi_232me.PDF")]
[InlineData("\u00e9\u00e0\u00e7.pdf", "_233_224_231.pdf")]
[InlineData("folder/file\\name.pdf", "folder_047file_092name.pdf")]
public void Filter_encodes_unsupported_characters_without_URL_fragments(
string originalName, string expectedName)
{
var filtered = AbstractFileSystemHelpers.FilterFileName(originalName);
Assert.Equal(expectedName, filtered);
Assert.DoesNotContain("#", filtered);
Assert.True(filtered.IsValidYavscPath());
Assert.Equal(filtered, AbstractFileSystemHelpers.FilterFileName(filtered));
}
[Fact]
public async Task Uploaded_accented_filename_is_accessible_through_static_files()
{
var root = Path.Combine(Path.GetTempPath(), $"yavsc-filename-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
var payload = System.Text.Encoding.UTF8.GetBytes("accented filename regression");
using var input = new MemoryStream(payload);
var file = new FormFile(input, 0, payload.Length, "file",
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF")
{
Headers = new HeaderDictionary(),
ContentType = "application/pdf"
};
var user = new ApplicationUser { DiskQuota = 10_000_000 };
var received = user.ReceiveUserFile(root, file);
Assert.False(received.QuotaOffense);
Assert.Equal("INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF",
received.FileName);
using var provider = new PhysicalFileProvider(root);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
await using var app = builder.Build();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = provider,
RequestPath = "/files"
});
await app.StartAsync(TestContext.Current.CancellationToken);
using var http = app.GetTestClient();
var uri = new Uri(http.BaseAddress!, $"/files/{received.FileName}");
Assert.Equal(string.Empty, uri.Fragment);
using var response = await http.GetAsync(uri, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
Assert.Equal(payload,
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
}

View file

@ -131,7 +131,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// on real files.
builder.Services.Configure<SiteSettings>(s =>
{
s.Blog = BlogFilesRoot;
s.UserFilesRoot = BlogFilesRoot;
});
// Real BlogSpotService — same instance the production host

View file

@ -49,7 +49,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
// BlogsWebServerFixture.
builder.Services.Configure<SiteSettings>(s =>
{
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
s.UserFilesRoot = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
});
builder.Services.AddScoped<BlogSpotService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();

View file

@ -9,7 +9,7 @@
"FavIcon": "/favicon.ico",
"Icon": "/images/yavsc.png",
"GitRepository": "testingrepo",
"Blog": "blog",
"UserFilesRoot": "files",
"Bills": "bills",
"TempDir": "temp",
"Avatars": "avatars",

View file

@ -115,7 +115,7 @@ public sealed class ServerServicesFixture : IDisposable
// Details itself doesn't read it, but the service constructor
// throws if SiteSettings.Blog is null, so configure a temp
// value to keep construction alive.
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
services.Configure<SiteSettings>(s => s.UserFilesRoot = "server-tests-files-root");
// Trivial file-system auth: Details never calls into it, but
// the DI container needs an instance to construct the service.