Compare commits
76 changed files with 274 additions and 2480 deletions
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
# 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.
|
|
||||||
21
.github/workflows/docker-publish-backend.yml
vendored
21
.github/workflows/docker-publish-backend.yml
vendored
|
|
@ -8,7 +8,8 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
production-deploy:
|
production-deploy:
|
||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# 1. Récupération du code
|
# 1. Récupération du code
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
|
|
@ -24,24 +25,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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
|
- name: Test
|
||||||
env:
|
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
|
||||||
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
|
# 4. Build et Push de l'image de production finale
|
||||||
- name: Build and push production image
|
- name: Build and push production image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
|
|
|
||||||
67
CHANGELOG.md
67
CHANGELOG.md
|
|
@ -1,73 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [1.0.8-rc26] - unstable
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
* [PostIt] Vue de lecture des billets par défaut, avec titre, auteur,
|
|
||||||
date et contenu Markdown défilant ; l'édition et la création restent
|
|
||||||
accessibles par des actions explicites, sans perdre le brouillon en
|
|
||||||
passant de la lecture à l'édition.
|
|
||||||
* [PostIt] Résolution des liens et images relatifs depuis
|
|
||||||
`<autorité Yavsc.Org>/files/<login auteur>/`, reconnaissance des fichiers
|
|
||||||
utilisateur sur cette origine et détection du type MIME par HTTP.
|
|
||||||
* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop,
|
|
||||||
avec lecture/pause, libération du lecteur à la fermeture et accès au
|
|
||||||
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
|
|
||||||
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
|
|
||||||
|
|
||||||
## [1.0.8-rc25] - unstable
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
* [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
|
## [1.0.8-rc24] - unstable
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,36 @@
|
||||||
# Utilisation de l'image globale pour le build et l'exécution
|
# Utilisation de l'image globale pour le build et l'exécution
|
||||||
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env
|
FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|
||||||
# 1. Copie de la solution
|
# 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 . .
|
COPY . .
|
||||||
RUN dotnet workload restore
|
|
||||||
# 4. Restauration des dépendances pour tous les projets
|
# 4. Restauration des dépendances pour tous les projets
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|
||||||
|
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
# 6. Compilation du serveur Web principal
|
# 6. Compilation du serveur Web principal
|
||||||
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||||
|
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -5,6 +5,8 @@ DESTDIR=/tmp/yavsc
|
||||||
APP_PATH=srv/www/yavsc
|
APP_PATH=srv/www/yavsc
|
||||||
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
|
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
|
||||||
|
|
||||||
|
include .env
|
||||||
|
|
||||||
all:
|
all:
|
||||||
dotnet build --nologo
|
dotnet build --nologo
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
|
|
@ -8,7 +8,7 @@
|
||||||
"CorsAllowedOrigins": [
|
"CorsAllowedOrigins": [
|
||||||
"https://preprod-api.$DOMAIN"
|
"https://preprod-api.$DOMAIN"
|
||||||
],
|
],
|
||||||
"UserFilesRoot": "files"
|
"Blog": "blog"
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"EMail": "paul@pschneider.fr"
|
"EMail": "paul@pschneider.fr"
|
||||||
},
|
},
|
||||||
"Quota": 200000000,
|
"Quota": 200000000,
|
||||||
"UserFilesRoot": "files",
|
"Blog": "blog",
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
|
|
|
||||||
|
|
@ -40,51 +40,6 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop).
|
||||||
Toute la logique (VM, services, navigation, settings, OIDC) est
|
Toute la logique (VM, services, navigation, settings, OIDC) est
|
||||||
dans le code-base partagé.
|
dans le code-base partagé.
|
||||||
|
|
||||||
## Lecture des billets et fichiers utilisateur
|
|
||||||
|
|
||||||
La page des blogs affiche par défaut une vue de lecture : titre, login
|
|
||||||
de l'auteur, date de modification et contenu Markdown dans une zone
|
|
||||||
défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et
|
|
||||||
**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche
|
|
||||||
le contenu enregistré sans effacer les modifications non enregistrées ;
|
|
||||||
un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé.
|
|
||||||
|
|
||||||
La base des ressources est dérivée de `Settings.Authentication.Authority`
|
|
||||||
(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` :
|
|
||||||
`https://<hôte Org>/files/<Author.UserName>/`. Le repli sur `AuthorId`
|
|
||||||
conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé.
|
|
||||||
Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ;
|
|
||||||
un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org.
|
|
||||||
Les liens absolus ne sont pas réécrits. Les images Markdown ``
|
|
||||||
sont affichées dans le billet et leur résolveur n'accepte que HTTP(S),
|
|
||||||
sans accès aux fichiers locaux du poste.
|
|
||||||
Le résolveur charge chaque image dans un flux mémoire repositionnable
|
|
||||||
initialement à la position zéro, nécessaire au décodage du lecteur Markdown.
|
|
||||||
|
|
||||||
Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible
|
|
||||||
Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur
|
|
||||||
conserve `MarkdownViewer.Core` : son gestionnaire intégré ouvre le navigateur
|
|
||||||
avant d'émettre l'événement de lien et ne permet donc pas ce routage.
|
|
||||||
|
|
||||||
Au clic sur un fichier sous `/files/<login>/...` de la même origine Org
|
|
||||||
(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec
|
|
||||||
`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse
|
|
||||||
`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une
|
|
||||||
extension connue sert de repli uniquement en l'absence de type exploitable
|
|
||||||
(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont
|
|
||||||
affichées dans la barre de statut, sans les masquer par une déduction MIME.
|
|
||||||
|
|
||||||
Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop.
|
|
||||||
Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et
|
|
||||||
une action d'ouverture externe. Le lecteur et ses ressources natives sont
|
|
||||||
arrêtés et libérés à la sortie de la page. **LibVLC doit être installé**
|
|
||||||
sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les
|
|
||||||
bibliothèques natives adaptées sont nécessaires sous Windows/macOS).
|
|
||||||
Une absence de LibVLC ou une erreur de décodage est signalée dans la page.
|
|
||||||
Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible,
|
|
||||||
les vidéos sont ouvertes dans le navigateur, comme les autres fichiers
|
|
||||||
(images liées, PDF, téléchargements) et les liens externes.
|
|
||||||
|
|
||||||
## ViewLocator custo
|
## ViewLocator custo
|
||||||
|
|
||||||
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
|
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
|
||||||
<PackageVersion Include="Markdown.Avalonia.Tight" Version="12.0.0-a3" />
|
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,6 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
||||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_recorder.Calls.Add((method, path, contentFactory));
|
_recorder.Calls.Add((method, path, contentFactory));
|
||||||
if (typeof(T) == typeof(BlogPostDto))
|
|
||||||
return Task.FromResult((T)(object)new BlogPostDto
|
|
||||||
{
|
|
||||||
Id = 42,
|
|
||||||
Title = "Mon premier billet",
|
|
||||||
AuthorId = "tester",
|
|
||||||
Article = "Contenu du billet de test.",
|
|
||||||
});
|
|
||||||
return Task.FromResult(default(T)!);
|
return Task.FromResult(default(T)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,8 @@ namespace PostIt.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BlogAttachmentLinkTests
|
public class BlogAttachmentLinkTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("note.txt", "note.txt")]
|
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
|
||||||
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
|
||||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
|
||||||
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
|
|
||||||
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
|
|
||||||
string originalName, string urlName)
|
|
||||||
{
|
{
|
||||||
const string authority = "https://idp.example.test";
|
const string authority = "https://idp.example.test";
|
||||||
|
|
||||||
|
|
@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests
|
||||||
vm.DraftTitle = "Après";
|
vm.DraftTitle = "Après";
|
||||||
vm.DraftArticle = "Contenu modifié.";
|
vm.DraftArticle = "Contenu modifié.";
|
||||||
vm.DraftAttachments.Add(new BlogUploadFile(
|
vm.DraftAttachments.Add(new BlogUploadFile(
|
||||||
originalName,
|
"note.txt",
|
||||||
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
||||||
"text/plain"));
|
"text/plain"));
|
||||||
|
|
||||||
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
|
|
||||||
Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle);
|
|
||||||
Assert.Empty(recorder.Calls);
|
|
||||||
var draftBeforeSave = vm.DraftArticle;
|
|
||||||
|
|
||||||
await vm.SaveAsync();
|
await vm.SaveAsync();
|
||||||
|
|
||||||
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
|
// 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);
|
||||||
Assert.NotEqual(default, linkPut);
|
Assert.NotEqual(default, linkPut);
|
||||||
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
|
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
|
||||||
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
|
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
|
||||||
|
|
||||||
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
|
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
|
||||||
|
var sentStringContent = multipartContent.First() as StringContent ;
|
||||||
|
|
||||||
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
using var document = System.Text.Json.JsonDocument.Parse(sentString);
|
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
|
||||||
var article = document.RootElement.GetProperty("article").GetString();
|
|
||||||
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
|
|
||||||
// The regression shape: a relative, authority-less link.
|
// The regression shape: a relative, authority-less link.
|
||||||
Assert.DoesNotContain("](/files/", article);
|
Assert.DoesNotContain("](/files/", sentString);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
using System.Net;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.Templates;
|
|
||||||
using Avalonia.Headless.XUnit;
|
|
||||||
using Avalonia.Logging;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Avalonia.VisualTree;
|
|
||||||
using Markdown.Avalonia;
|
|
||||||
using ColorTextBlock.Avalonia;
|
|
||||||
using PostIt.Models;
|
|
||||||
using PostIt.Services;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using PostIt.Views;
|
|
||||||
using PostIt.Views.Blogs;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
using Yavsc.Blogspot;
|
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
public class BlogReaderTests
|
|
||||||
{
|
|
||||||
private static BlogPostDto Post(string article = "Texte du billet.") => new()
|
|
||||||
{
|
|
||||||
Id = 42,
|
|
||||||
Title = "Un billet à lire",
|
|
||||||
Article = article,
|
|
||||||
AuthorId = "user-id-not-login",
|
|
||||||
Author = new BlogPostAuthorDto { UserName = "paul" },
|
|
||||||
DateModified = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
|
|
||||||
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
|
|
||||||
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
|
|
||||||
resources: resources);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
|
|
||||||
{
|
|
||||||
var vm = CreateViewModel();
|
|
||||||
vm.SelectedPost = Post();
|
|
||||||
Assert.False(vm.IsEditing);
|
|
||||||
Assert.Equal("paul", vm.ReaderAuthor);
|
|
||||||
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
|
|
||||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
|
|
||||||
{
|
|
||||||
var vm = CreateViewModel();
|
|
||||||
vm.SelectedPost = Post();
|
|
||||||
vm.EditPostCommand.Execute(null);
|
|
||||||
vm.DraftArticle = "Modifications non enregistrées";
|
|
||||||
vm.ReadPostCommand.Execute(null);
|
|
||||||
Assert.False(vm.IsEditing);
|
|
||||||
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
|
||||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
|
||||||
Assert.True(vm.IsDraftModified);
|
|
||||||
vm.EditPostCommand.Execute(null);
|
|
||||||
Assert.True(vm.IsEditing);
|
|
||||||
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
|
|
||||||
{
|
|
||||||
var vm = CreateViewModel();
|
|
||||||
vm.SelectedPost = Post();
|
|
||||||
vm.NewPostCommand.Execute(null);
|
|
||||||
Assert.True(vm.IsEditing);
|
|
||||||
Assert.Null(vm.SelectedPost);
|
|
||||||
Assert.Empty(vm.DraftTitle);
|
|
||||||
Assert.Empty(vm.DraftArticle!);
|
|
||||||
vm.DraftTitle = "Nouveau brouillon";
|
|
||||||
vm.NewPostCommand.Execute(null);
|
|
||||||
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
|
|
||||||
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
|
|
||||||
{
|
|
||||||
var vm = CreateViewModel();
|
|
||||||
await vm.InitializeAsync();
|
|
||||||
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
|
|
||||||
var page = new BlogsPage { DataContext = vm };
|
|
||||||
var nav = new NavigationPage();
|
|
||||||
await nav.PushAsync(page);
|
|
||||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
|
||||||
try
|
|
||||||
{
|
|
||||||
window.Show();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
window.UpdateLayout();
|
|
||||||
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
|
|
||||||
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
|
||||||
Assert.False(page.SaveButton.IsVisible);
|
|
||||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
|
||||||
Assert.Equal(vm.ReaderArticle, reader.Markdown);
|
|
||||||
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
|
|
||||||
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
|
|
||||||
vm.EditPostCommand.Execute(null);
|
|
||||||
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
|
|
||||||
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
|
||||||
Assert.True(page.SaveButton.IsVisible);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors()
|
|
||||||
{
|
|
||||||
var vm = CreateViewModel();
|
|
||||||
await vm.InitializeAsync();
|
|
||||||
var post = Post();
|
|
||||||
vm.Posts.Add(post);
|
|
||||||
vm.FilteredPosts.Add(post);
|
|
||||||
var previousSink = Logger.Sink;
|
|
||||||
var sink = new RecordingLogSink();
|
|
||||||
Logger.Sink = sink;
|
|
||||||
Window? window = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var page = new BlogsPage();
|
|
||||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
|
||||||
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
|
||||||
Assert.NotNull(engine.HyperlinkCommand);
|
|
||||||
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
|
|
||||||
page.DataContext = vm;
|
|
||||||
var nav = new NavigationPage();
|
|
||||||
await nav.PushAsync(page);
|
|
||||||
window = new Window { Width = 800, Height = 700, Content = nav };
|
|
||||||
window.Show();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Empty(vm.ReaderTitle);
|
|
||||||
Assert.Null(vm.ReaderDateModified);
|
|
||||||
Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand);
|
|
||||||
Assert.IsType<BlogImageResolver>(reader.Plugins.PathResolver);
|
|
||||||
|
|
||||||
vm.SelectedPost = post;
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Equal(post.Title, vm.ReaderTitle);
|
|
||||||
Assert.Equal(post.DateModified, vm.ReaderDateModified);
|
|
||||||
|
|
||||||
vm.SelectedPost = null;
|
|
||||||
page.DataContext = null;
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Empty(vm.ReaderTitle);
|
|
||||||
Assert.Null(vm.ReaderDateModified);
|
|
||||||
Assert.NotNull(engine.HyperlinkCommand);
|
|
||||||
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
|
|
||||||
Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window?.Close();
|
|
||||||
Logger.Sink = previousSink;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
|
|
||||||
{
|
|
||||||
const string url = "https://org.example.test/files/paul/absent.jpg";
|
|
||||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(
|
|
||||||
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
|
|
||||||
var resolver = new BlogImageResolver(client);
|
|
||||||
var previousSink = Logger.Sink;
|
|
||||||
var sink = new RecordingLogSink();
|
|
||||||
Logger.Sink = sink;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Assert.ThrowsAsync<HttpRequestException>(() => resolver.ResolveImageResource(url));
|
|
||||||
var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages");
|
|
||||||
Assert.Contains(url, error.Values);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Logger.Sink = previousSink;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class RecordingLogSink : ILogSink
|
|
||||||
{
|
|
||||||
public List<(string Area, object?[] Values)> Entries { get; } = [];
|
|
||||||
public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning;
|
|
||||||
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
|
|
||||||
=> Entries.Add((area, []));
|
|
||||||
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values)
|
|
||||||
=> Entries.Add((area, values));
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
|
|
||||||
{
|
|
||||||
var vm = CreateViewModel();
|
|
||||||
await vm.InitializeAsync();
|
|
||||||
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
|
|
||||||
var page = new BlogsPage { DataContext = vm };
|
|
||||||
var nav = new NavigationPage();
|
|
||||||
await nav.PushAsync(page);
|
|
||||||
var window = new Window { Width = 800, Height = 700, Content = nav };
|
|
||||||
try
|
|
||||||
{
|
|
||||||
window.Show();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
window.UpdateLayout();
|
|
||||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
|
||||||
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
|
||||||
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
|
|
||||||
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
|
|
||||||
scroll.ScrollToEnd();
|
|
||||||
Assert.True(scroll.Offset.Y > 0);
|
|
||||||
vm.SelectedPost = null;
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Empty(reader.Markdown!);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
|
|
||||||
[InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
|
|
||||||
[InlineData("blogs/14/IMG-20260923-WA0001.jpg")]
|
|
||||||
public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url)
|
|
||||||
{
|
|
||||||
using var source = new SKBitmap(32, 24);
|
|
||||||
source.Erase(SKColors.CornflowerBlue);
|
|
||||||
using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90);
|
|
||||||
var jpeg = encoded.ToArray();
|
|
||||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
|
||||||
{
|
|
||||||
Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg",
|
|
||||||
request.RequestUri!.AbsoluteUri);
|
|
||||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StreamContent(new NonSeekableStream(jpeg))
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
var resolver = new BlogImageResolver(client)
|
|
||||||
{
|
|
||||||
AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/"
|
|
||||||
};
|
|
||||||
await using var stream = await resolver.ResolveImageResource(url);
|
|
||||||
Assert.NotNull(stream);
|
|
||||||
Assert.True(stream.CanSeek);
|
|
||||||
Assert.Equal(0, stream.Position);
|
|
||||||
using var decoded = SKBitmap.Decode(stream);
|
|
||||||
Assert.NotNull(decoded);
|
|
||||||
Assert.Equal(32, decoded.Width);
|
|
||||||
Assert.Equal(24, decoded.Height);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class NonSeekableStream(byte[] bytes) : Stream
|
|
||||||
{
|
|
||||||
private readonly MemoryStream _inner = new(bytes, writable: false);
|
|
||||||
public override bool CanRead => _inner.CanRead;
|
|
||||||
public override bool CanSeek => false;
|
|
||||||
public override bool CanWrite => false;
|
|
||||||
public override long Length => throw new NotSupportedException();
|
|
||||||
public override long Position
|
|
||||||
{
|
|
||||||
get => throw new NotSupportedException();
|
|
||||||
set => throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
|
||||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
|
||||||
=> _inner.ReadAsync(buffer, cancellationToken);
|
|
||||||
public override void Flush() => throw new NotSupportedException();
|
|
||||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
||||||
public override void SetLength(long value) => throw new NotSupportedException();
|
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
_inner.Dispose();
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
|
|
||||||
{
|
|
||||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
|
||||||
{
|
|
||||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
|
|
||||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
|
||||||
response.Content.Headers.ContentType = new("video/mp4");
|
|
||||||
return response;
|
|
||||||
}));
|
|
||||||
var vm = CreateViewModel(new BlogResourceService(client));
|
|
||||||
vm.SelectedPost = Post();
|
|
||||||
var app = (App)Application.Current!;
|
|
||||||
var root = new MainView();
|
|
||||||
app.AttachMainWindow(root);
|
|
||||||
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
|
|
||||||
app.DataTemplates.Insert(0, template);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var page = new BlogsPage { DataContext = vm };
|
|
||||||
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
|
||||||
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
|
|
||||||
Assert.NotNull(link.Command);
|
|
||||||
link.Command(link.CommandParameter!);
|
|
||||||
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
|
|
||||||
await vm.OpenResourceCommand.ExecutionTask;
|
|
||||||
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
|
|
||||||
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
|
|
||||||
Assert.Equal("video/mp4", target.MimeType);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
app.DataTemplates.Remove(template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
|
|
||||||
{
|
|
||||||
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
|
||||||
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
|
||||||
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
|
|
||||||
{
|
|
||||||
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
|
|
||||||
var vm = CreateViewModel(new BlogResourceService(client));
|
|
||||||
vm.SelectedPost = Post();
|
|
||||||
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
|
|
||||||
Assert.False(vm.IsBusy);
|
|
||||||
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using PostIt.Services;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
public class BlogResourceServiceTests
|
|
||||||
{
|
|
||||||
private const string Authority = "https://yavsc.pschneider.fr";
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
|
|
||||||
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
|
|
||||||
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
|
|
||||||
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
|
|
||||||
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
|
|
||||||
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
|
|
||||||
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
|
|
||||||
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Encodes_login_and_keeps_trailing_slash()
|
|
||||||
=> Assert.Equal(Authority + "/files/paul%20test/",
|
|
||||||
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(Authority + "/files/paul/movie.mp4", true)]
|
|
||||||
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
|
|
||||||
[InlineData(Authority + "/files/paul/", false)]
|
|
||||||
[InlineData(Authority + "/files/paul", false)]
|
|
||||||
[InlineData(Authority + "/files/", false)]
|
|
||||||
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
|
|
||||||
[InlineData(Authority + "/page.mp4", false)]
|
|
||||||
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
|
|
||||||
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
|
|
||||||
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
|
|
||||||
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
|
||||||
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
|
||||||
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
|
|
||||||
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("javascript:alert(1)")]
|
|
||||||
[InlineData("file:///tmp/video.mp4")]
|
|
||||||
[InlineData("data:video/mp4;base64,AAAA")]
|
|
||||||
public void Rejects_non_web_links(string link)
|
|
||||||
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("video/webm", "movie.jpg", "video/webm")]
|
|
||||||
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
|
|
||||||
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
|
|
||||||
[InlineData(null, "movie.webm", "video/webm")]
|
|
||||||
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
|
|
||||||
[InlineData(null, "file.unknown", "application/octet-stream")]
|
|
||||||
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
|
|
||||||
{
|
|
||||||
using var client = new HttpClient(new StubHandler(request =>
|
|
||||||
{
|
|
||||||
Assert.Equal(HttpMethod.Head, request.Method);
|
|
||||||
return Response(HttpStatusCode.OK, mime);
|
|
||||||
}));
|
|
||||||
var service = new BlogResourceService(client);
|
|
||||||
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
|
|
||||||
TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(HttpStatusCode.MethodNotAllowed)]
|
|
||||||
[InlineData(HttpStatusCode.NotImplemented)]
|
|
||||||
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
|
|
||||||
{
|
|
||||||
var calls = 0;
|
|
||||||
using var client = new HttpClient(new StubHandler(request =>
|
|
||||||
{
|
|
||||||
calls++;
|
|
||||||
if (request.Method == HttpMethod.Head)
|
|
||||||
return Response(status, null);
|
|
||||||
Assert.Equal(HttpMethod.Get, request.Method);
|
|
||||||
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
|
|
||||||
return Response(HttpStatusCode.PartialContent, "video/mp4");
|
|
||||||
}));
|
|
||||||
Assert.Equal("video/mp4", await new BlogResourceService(client)
|
|
||||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
|
|
||||||
Assert.Equal(2, calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(HttpStatusCode.NotFound)]
|
|
||||||
[InlineData(HttpStatusCode.Unauthorized)]
|
|
||||||
[InlineData(HttpStatusCode.InternalServerError)]
|
|
||||||
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
|
|
||||||
{
|
|
||||||
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
|
|
||||||
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
|
|
||||||
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
|
|
||||||
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
|
||||||
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
|
||||||
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
|
|
||||||
{
|
|
||||||
using var client = new HttpClient(new StubHandler(request =>
|
|
||||||
{
|
|
||||||
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
|
|
||||||
return Response(HttpStatusCode.OK, "image/jpeg");
|
|
||||||
}));
|
|
||||||
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
|
|
||||||
await using var stream = await resolver.ResolveImageResource(link);
|
|
||||||
Assert.NotNull(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("file:///etc/passwd")]
|
|
||||||
[InlineData("data:image/png;base64,AAAA")]
|
|
||||||
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
|
|
||||||
{
|
|
||||||
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
|
|
||||||
{
|
|
||||||
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
|
|
||||||
if (mime is not null)
|
|
||||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
|
||||||
{
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
||||||
=> Task.FromResult(respond(request));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,7 +42,6 @@ public class MainPageSaveTests
|
||||||
var api = new RecordingYavscApiClient(recorder);
|
var api = new RecordingYavscApiClient(recorder);
|
||||||
var blog = new BlogApiClient(api, "http://localhost/");
|
var blog = new BlogApiClient(api, "http://localhost/");
|
||||||
var viewModel = new BlogsViewModel(blog);
|
var viewModel = new BlogsViewModel(blog);
|
||||||
viewModel.NewPostCommand.Execute(null);
|
|
||||||
|
|
||||||
var page = new BlogsPage { DataContext = viewModel };
|
var page = new BlogsPage { DataContext = viewModel };
|
||||||
// MainPage is a ContentPage (a Page, not a Control), so it
|
// MainPage is a ContentPage (a Page, not a Control), so it
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ public class MarkdownEditorScrollTests
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
public void Blog_page_bounds_the_article_viewport(bool preview)
|
public void Blog_page_bounds_the_article_viewport(bool preview)
|
||||||
{
|
{
|
||||||
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
|
var page = new BlogsPage();
|
||||||
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
||||||
editor.MarkdownText = LongArticle;
|
editor.MarkdownText = LongArticle;
|
||||||
editor.IsPreviewVisible = preview;
|
editor.IsPreviewVisible = preview;
|
||||||
|
|
|
||||||
|
|
@ -12,91 +12,6 @@ namespace PostIt.Tests;
|
||||||
|
|
||||||
public class PostItViewModelTests
|
public class PostItViewModelTests
|
||||||
{
|
{
|
||||||
[Fact]
|
|
||||||
public void Draft_modified_tracks_title_body_and_attachments_not_publication()
|
|
||||||
{
|
|
||||||
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
|
|
||||||
vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
|
|
||||||
Assert.False(vm.IsDraftModified);
|
|
||||||
vm.DraftIsPublished = true;
|
|
||||||
Assert.False(vm.IsDraftModified);
|
|
||||||
vm.DraftTitle = "Autre";
|
|
||||||
Assert.True(vm.IsDraftModified);
|
|
||||||
vm.DraftTitle = "Titre";
|
|
||||||
Assert.False(vm.IsDraftModified);
|
|
||||||
vm.DraftArticleDocument!.Insert(0, "Edit ");
|
|
||||||
Assert.True(vm.IsDraftModified);
|
|
||||||
vm.DraftArticle = "Corps";
|
|
||||||
Assert.False(vm.IsDraftModified);
|
|
||||||
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
|
|
||||||
Assert.True(vm.IsDraftModified);
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
|
||||||
public async Task Save_keeps_selection_and_editor_open_and_resets_highlight()
|
|
||||||
{
|
|
||||||
var recorder = new CallRecorder();
|
|
||||||
var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder),
|
|
||||||
"https://blogs.example.test/api/v1/"));
|
|
||||||
var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
|
|
||||||
vm.Posts.Add(selected);
|
|
||||||
vm.SearchText = "";
|
|
||||||
vm.SelectedPost = selected;
|
|
||||||
var page = new BlogsPage { DataContext = vm };
|
|
||||||
var navigation = new NavigationPage();
|
|
||||||
await navigation.PushAsync(page);
|
|
||||||
var window = new Window { Content = navigation };
|
|
||||||
try
|
|
||||||
{
|
|
||||||
window.Show();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
selected = Assert.Single(vm.FilteredPosts);
|
|
||||||
selected.AuthorId = "tester";
|
|
||||||
selected.Title = "Titre";
|
|
||||||
selected.Article = "Corps";
|
|
||||||
page.PostsListBox.SelectedItem = selected;
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Same(selected, page.PostsListBox.SelectedItem);
|
|
||||||
Assert.Same(selected, vm.SelectedPost);
|
|
||||||
Assert.DoesNotContain("modified", page.SaveButton.Classes);
|
|
||||||
vm.DraftTitle = "Titre modifié";
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Contains("modified", page.SaveButton.Classes);
|
|
||||||
Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight);
|
|
||||||
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
|
|
||||||
await vm.SaveAsync();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
Assert.Same(page, navigation.NavigationStack.Last());
|
|
||||||
Assert.Same(selected, vm.SelectedPost);
|
|
||||||
Assert.Same(selected, page.PostsListBox.SelectedItem);
|
|
||||||
Assert.Equal("Titre modifié", vm.DraftTitle);
|
|
||||||
Assert.Equal("Corps", vm.DraftArticle);
|
|
||||||
Assert.DoesNotContain("modified", page.SaveButton.Classes);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Failed_save_preserves_draft_attachments_and_modified_state()
|
|
||||||
{
|
|
||||||
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
|
|
||||||
var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
|
|
||||||
vm.SelectedPost = post;
|
|
||||||
vm.DraftTitle = "Titre modifié";
|
|
||||||
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
|
|
||||||
var article = vm.DraftArticle;
|
|
||||||
await vm.SaveAsync();
|
|
||||||
Assert.Same(post, vm.SelectedPost);
|
|
||||||
Assert.Equal("Titre modifié", vm.DraftTitle);
|
|
||||||
Assert.Equal(article, vm.DraftArticle);
|
|
||||||
Assert.Single(vm.DraftAttachments);
|
|
||||||
Assert.True(vm.IsDraftModified);
|
|
||||||
Assert.False(vm.IsBusy);
|
|
||||||
}
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Published_badge_tracks_post_publication_state()
|
public void Published_badge_tracks_post_publication_state()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Headless.XUnit;
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.Styling;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
using PostIt.Views;
|
using PostIt.Views;
|
||||||
|
|
@ -22,36 +23,41 @@ namespace PostIt.Tests;
|
||||||
/// <c>new Window().Show()</c> throws because the harness has not
|
/// <c>new Window().Show()</c> throws because the harness has not
|
||||||
/// been initialised for that thread.</para>
|
/// been initialised for that thread.</para>
|
||||||
///
|
///
|
||||||
/// <para>These tests set the DataContext directly on the banner
|
/// <para>The session banner's <c>DataContext</c> is not wired in
|
||||||
/// mounted in the window. The production code path is exercised
|
/// 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
|
||||||
/// end-to-end by the manual launch, not here.</para>
|
/// end-to-end by the manual launch, not here.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SessionStatusBannerTests : IDisposable
|
public class SessionStatusBannerTests
|
||||||
{
|
{
|
||||||
private MainWindow? _window;
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_renders_four_buttons_in_the_visual_tree()
|
public void Banner_renders_three_buttons_in_the_visual_tree()
|
||||||
{
|
{
|
||||||
SessionStatusBanner banner = CreateBanner();
|
MainWindow window = new MainWindow();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var buttons = banner.GetVisualDescendants()
|
var buttons = window.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.Equal(4, buttons.Count);
|
// 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.Contains(buttons, b => b.Content as string == "Se déconnecter");
|
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 == "Se connecter");
|
||||||
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
|
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
|
||||||
Assert.Contains(buttons, b => b.Content as string == "À propos");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_login_button_is_visible_when_logged_out()
|
public void Banner_login_button_is_visible_when_logged_out()
|
||||||
{
|
{
|
||||||
SessionStatusBanner banner = CreateBanner();
|
MainWindow window = new MainWindow();
|
||||||
|
window.Show();
|
||||||
|
|
||||||
var login = banner.GetVisualDescendants()
|
var login = window.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Se connecter");
|
.Single(b => b.Content as string == "Se connecter");
|
||||||
|
|
||||||
|
|
@ -63,9 +69,12 @@ public class SessionStatusBannerTests : IDisposable
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_logout_button_is_hidden_when_logged_out()
|
public void Banner_logout_button_is_hidden_when_logged_out()
|
||||||
{
|
{
|
||||||
SessionStatusBanner banner = CreateBanner();
|
SessionStatusBanner banner = CreateBanner();
|
||||||
|
|
||||||
|
Assert.False((banner.DataContext as SessionStatusViewModel)!
|
||||||
|
.IsLoggedIn); // VM default
|
||||||
|
|
||||||
|
|
||||||
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
|
|
||||||
var logout = banner.GetVisualDescendants()
|
var logout = banner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Se déconnecter");
|
.Single(b => b.Content as string == "Se déconnecter");
|
||||||
|
|
@ -82,23 +91,22 @@ public class SessionStatusBannerTests : IDisposable
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Paramètres");
|
.Single(b => b.Content as string == "Paramètres");
|
||||||
|
|
||||||
// Paramètres has no IsVisible binding and is always shown.
|
// 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.
|
||||||
Assert.True(settings.IsVisible);
|
Assert.True(settings.IsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionStatusBanner CreateBanner()
|
private static SessionStatusBanner CreateBanner()
|
||||||
{
|
{
|
||||||
_window = new MainWindow();
|
MainWindow window = new MainWindow();
|
||||||
_window.Show();
|
window.Show();
|
||||||
var banner = _window.GetVisualDescendants()
|
var banner = new SessionStatusBanner();
|
||||||
.OfType<SessionStatusBanner>()
|
var status = new SessionStatusViewModel();
|
||||||
.Single();
|
banner.DataContext = status;
|
||||||
banner.DataContext = new SessionStatusViewModel();
|
|
||||||
return banner;
|
return banner;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _window?.Close();
|
|
||||||
|
|
||||||
[AvaloniaFact]
|
[AvaloniaFact]
|
||||||
public void Banner_session_label_reflects_DataContext()
|
public void Banner_session_label_reflects_DataContext()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,56 +17,6 @@ namespace PostIt.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class YavscApiClientTests
|
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()
|
private static int GetFreePort()
|
||||||
{
|
{
|
||||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ public partial class App : Application, ILiveView, IVMPusherApp
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
this.AttachDeveloperTools();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ public static class ServiceCollectionHelpers
|
||||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||||
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
|
||||||
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
|
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||||
|
|
||||||
|
|
@ -44,7 +43,6 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<MainWindow>();
|
services.AddTransient<MainWindow>();
|
||||||
services.AddTransient<MainView>();
|
services.AddTransient<MainView>();
|
||||||
services.AddTransient<BlogsPage>();
|
services.AddTransient<BlogsPage>();
|
||||||
services.AddTransient<VideoPage>();
|
|
||||||
|
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
|
|
@ -95,7 +93,6 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(estimateClient);
|
services.AddSingleton(estimateClient);
|
||||||
services.AddSingleton(frontClient);
|
services.AddSingleton(frontClient);
|
||||||
services.AddSingleton(resourceUsageClient);
|
services.AddSingleton(resourceUsageClient);
|
||||||
services.AddSingleton(notificationsClient);
|
|
||||||
services.AddSingleton(userFilesClient);
|
services.AddSingleton(userFilesClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="IdentityModel.OidcClient" />
|
<PackageReference Include="IdentityModel.OidcClient" />
|
||||||
<PackageReference Include="MarkdownViewer.Core" />
|
<PackageReference Include="MarkdownViewer.Core" />
|
||||||
<PackageReference Include="Markdown.Avalonia.Tight" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Mono.Options" />
|
<PackageReference Include="Mono.Options" />
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Markdown.Avalonia.Utils;
|
|
||||||
using Avalonia.Logging;
|
|
||||||
|
|
||||||
namespace PostIt.Services;
|
|
||||||
|
|
||||||
public sealed class BlogImageResolver : IPathResolver
|
|
||||||
{
|
|
||||||
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) };
|
|
||||||
private readonly HttpClient _client;
|
|
||||||
|
|
||||||
public BlogImageResolver() : this(Client) { }
|
|
||||||
|
|
||||||
public BlogImageResolver(HttpClient client) => _client = client;
|
|
||||||
|
|
||||||
public string? AssetPathRoot { get; set; }
|
|
||||||
public IEnumerable<string>? CallerAssemblyNames { get; set; }
|
|
||||||
|
|
||||||
public async Task<Stream?> ResolveImageResource(string relativeOrAbsolutePath)
|
|
||||||
{
|
|
||||||
var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal)
|
|
||||||
&& Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute)
|
|
||||||
? absolute
|
|
||||||
: new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException(
|
|
||||||
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
|
|
||||||
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
|
||||||
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// The Markdown decoder needs a seekable stream positioned at the start.
|
|
||||||
return new MemoryStream(await _client.GetByteArrayAsync(uri), writable: false);
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
|
||||||
{
|
|
||||||
Logger.TryGet(LogEventLevel.Error, "BlogImages")?.Log(this,
|
|
||||||
"Unable to load blog image {Url}: {Error}", uri.AbsoluteUri, ex.Message);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Yavsc.Abstract.Files;
|
|
||||||
|
|
||||||
namespace PostIt.Services;
|
|
||||||
|
|
||||||
public sealed class BlogResourceService
|
|
||||||
{
|
|
||||||
private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
|
||||||
private readonly HttpClient _client;
|
|
||||||
|
|
||||||
public BlogResourceService() : this(SharedClient) { }
|
|
||||||
|
|
||||||
public BlogResourceService(HttpClient client) => _client = client;
|
|
||||||
|
|
||||||
public static Uri GetUserBaseUri(string authority, string login)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(login))
|
|
||||||
throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers.");
|
|
||||||
return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Uri Resolve(string link, string authority, string login)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(link))
|
|
||||||
throw new ArgumentException("Le lien est vide.", nameof(link));
|
|
||||||
var origin = GetAuthority(authority);
|
|
||||||
var uri = link.StartsWith("/", StringComparison.Ordinal)
|
|
||||||
? new Uri(origin, link)
|
|
||||||
: Uri.TryCreate(link, UriKind.Absolute, out var absolute)
|
|
||||||
? absolute
|
|
||||||
: new Uri(GetUserBaseUri(authority, login), link);
|
|
||||||
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
|
||||||
throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge.");
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsUserResource(Uri uri, string authority)
|
|
||||||
{
|
|
||||||
var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority));
|
|
||||||
if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo))
|
|
||||||
return false;
|
|
||||||
var path = uri.AbsolutePath[root.AbsolutePath.Length..];
|
|
||||||
var separator = path.IndexOf('/');
|
|
||||||
return separator > 0 && separator < path.Length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
|
||||||
using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
||||||
if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
|
|
||||||
{
|
|
||||||
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
|
||||||
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
|
|
||||||
using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
||||||
fallback.EnsureSuccessStatusCode();
|
|
||||||
return GetMimeType(fallback, uri);
|
|
||||||
}
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return GetMimeType(response, uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetMimeType(HttpResponseMessage response, Uri uri)
|
|
||||||
{
|
|
||||||
var mime = response.Content.Headers.ContentType?.MediaType;
|
|
||||||
if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return mime;
|
|
||||||
return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
".mp4" or ".m4v" => "video/mp4",
|
|
||||||
".webm" => "video/webm",
|
|
||||||
".ogv" => "video/ogg",
|
|
||||||
".ogg" => "audio/ogg",
|
|
||||||
".mov" => "video/quicktime",
|
|
||||||
".mkv" => "video/x-matroska",
|
|
||||||
".avi" => "video/x-msvideo",
|
|
||||||
".jpg" or ".jpeg" => "image/jpeg",
|
|
||||||
".png" => "image/png",
|
|
||||||
".gif" => "image/gif",
|
|
||||||
".webp" => "image/webp",
|
|
||||||
".pdf" => "application/pdf",
|
|
||||||
_ => mime ?? "application/octet-stream"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Uri GetAuthority(string authority)
|
|
||||||
{
|
|
||||||
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)
|
|
||||||
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
|
||||||
|| !string.IsNullOrEmpty(uri.UserInfo))
|
|
||||||
throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification.");
|
|
||||||
return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -39,16 +39,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||||
|
|
||||||
private RefreshTokenRecord? _tokens;
|
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)
|
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
||||||
{
|
{
|
||||||
|
|
@ -624,8 +614,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
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(
|
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||||
"Bearer", _owner._tokens.AccessToken);
|
"Bearer", _owner._tokens.AccessToken);
|
||||||
return base.SendAsync(request, cancellationToken);
|
return base.SendAsync(request, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ public class ViewLocator : IDataTemplate
|
||||||
return data switch
|
return data switch
|
||||||
{
|
{
|
||||||
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
||||||
VideoPageViewModel => services.GetRequiredService<VideoPage>(),
|
|
||||||
Settings => services.GetRequiredService<SettingsPage>(),
|
Settings => services.GetRequiredService<SettingsPage>(),
|
||||||
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
||||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
@ -11,10 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Abstract.Files;
|
using Yavsc.Abstract.Files;
|
||||||
using Yavsc.Server.Helpers;
|
|
||||||
using AvaloniaEdit.Document;
|
using AvaloniaEdit.Document;
|
||||||
using Avalonia.Controls;
|
|
||||||
using PostIt.Services;
|
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -92,14 +88,12 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(DraftArticleDocument));
|
OnPropertyChanged(nameof(DraftArticleDocument));
|
||||||
UpdateDraftModified();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
|
private void DraftArticleDocument_TextChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(DraftArticle));
|
OnPropertyChanged(nameof(DraftArticle));
|
||||||
UpdateDraftModified();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Editor buffer for the post's publication state.
|
/// <summary>Editor buffer for the post's publication state.
|
||||||
|
|
@ -113,14 +107,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
/// mutable field. Toggling is its own action.</summary>
|
/// mutable field. Toggling is its own action.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool DraftIsPublished { get; set; }
|
public partial bool DraftIsPublished { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial bool IsDraftModified { get; private set; }
|
|
||||||
|
|
||||||
private bool _preserveDraft;
|
|
||||||
private string _savedTitle = string.Empty;
|
|
||||||
private string _savedArticle = string.Empty;
|
|
||||||
private readonly Dictionary<string, string> _pendingAttachmentLinks = new();
|
|
||||||
public bool IsLoaded { get; private set; }
|
public bool IsLoaded { get; private set; }
|
||||||
public Settings SettingsModel { get; }
|
public Settings SettingsModel { get; }
|
||||||
|
|
||||||
|
|
@ -143,87 +129,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
|
public partial ObservableCollection<BlogUploadFile> DraftAttachments { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ReaderArticle))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ReaderAuthor))]
|
|
||||||
public partial BlogPostDto? SelectedPost { get; set; }
|
public partial BlogPostDto? SelectedPost { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial bool IsEditing { get; set; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial string? ReaderBaseUrl { get; private set; }
|
|
||||||
|
|
||||||
public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
|
|
||||||
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty;
|
|
||||||
public string ReaderTitle => SelectedPost?.Title ?? string.Empty;
|
|
||||||
public DateTime? ReaderDateModified => SelectedPost?.DateModified;
|
|
||||||
|
|
||||||
private readonly BlogResourceService _resources = new();
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void NewPost()
|
|
||||||
{
|
|
||||||
if (IsDraftModified)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Enregistrez vos modifications avant de créer un autre billet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
SelectedPost = null;
|
|
||||||
DraftTitle = string.Empty;
|
|
||||||
DraftArticle = string.Empty;
|
|
||||||
DraftAttachments.Clear();
|
|
||||||
_pendingAttachmentLinks.Clear();
|
|
||||||
DraftIsPublished = false;
|
|
||||||
MarkDraftSaved();
|
|
||||||
IsEditing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void EditPost() => IsEditing = true;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ReadPost()
|
|
||||||
{
|
|
||||||
IsEditing = false;
|
|
||||||
UpdateReader();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task OpenResourceAsync(string? link)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
var uri = BlogResourceService.Resolve(link ?? string.Empty,
|
|
||||||
Settings.Authentication.Authority, ReaderAuthor);
|
|
||||||
if (BlogResourceService.IsUserResource(uri, Settings.Authentication.Authority))
|
|
||||||
{
|
|
||||||
this.SetInfoStatus("Détection du type de fichier...");
|
|
||||||
var mime = await _resources.GetMimeTypeAsync(uri);
|
|
||||||
if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !OperatingSystem.IsBrowser() && !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS())
|
|
||||||
{
|
|
||||||
await ((App)Application.Current!).PushPageAsync(new VideoPageViewModel(uri, mime));
|
|
||||||
this.SetInfoStatus($"Vidéo : {mime}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.SetInfoStatus($"Ouverture dans le navigateur ({mime}).");
|
|
||||||
}
|
|
||||||
var topLevel = TopLevel.GetTopLevel(((App)Application.Current!).View!)
|
|
||||||
?? throw new InvalidOperationException("La fenêtre de l'application est indisponible.");
|
|
||||||
if (!await topLevel.Launcher.LaunchUriAsync(uri))
|
|
||||||
throw new InvalidOperationException("Le système n'a pas pu ouvrir ce lien.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.SetErrorStatus($"Impossible d'ouvrir le lien : {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
|
@ -249,7 +156,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
[RelayCommand]
|
||||||
internal async Task SaveAsync()
|
internal async Task SaveAsync()
|
||||||
{
|
{
|
||||||
// The button is already disabled when the title is empty
|
// The button is already disabled when the title is empty
|
||||||
|
|
@ -267,6 +174,16 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
var attachments = DraftAttachments.ToArray();
|
var attachments = DraftAttachments.ToArray();
|
||||||
|
|
||||||
|
// Build a fresh BlogPostDto from the editor buffer on
|
||||||
|
// every Save — we no longer mutate SelectedPost in
|
||||||
|
// place. The previous behavior copied the buffer
|
||||||
|
// (which was a no-op when SelectedPost was null)
|
||||||
|
// back onto the model and relied on a
|
||||||
|
// [Required] violation to surface the missing
|
||||||
|
// input; the new shape keeps the editor buffer as
|
||||||
|
// the single source of truth for outgoing payloads
|
||||||
|
// and the selected post as a read-only hint for
|
||||||
|
// the update path.
|
||||||
if (SelectedPost is null || SelectedPost.Id == 0)
|
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||||
{
|
{
|
||||||
var draft = new BlogPostDto
|
var draft = new BlogPostDto
|
||||||
|
|
@ -277,22 +194,12 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
IsPublished = DraftIsPublished
|
IsPublished = DraftIsPublished
|
||||||
};
|
};
|
||||||
var savedTitle = draft.Title;
|
|
||||||
var savedArticle = draft.Article;
|
|
||||||
var created = await BlogClient!.CreatePostAsync(draft, attachments);
|
var created = await BlogClient!.CreatePostAsync(draft, attachments);
|
||||||
if (created is not null)
|
if (created is not null)
|
||||||
{
|
{
|
||||||
_preserveDraft = true;
|
SelectedPost = created;
|
||||||
try
|
|
||||||
{
|
|
||||||
SelectedPost = created;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_preserveDraft = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ResolvePendingAttachmentLinks(created))
|
if (TryAppendAttachmentLinks(created, attachments))
|
||||||
{
|
{
|
||||||
var linkUpdate = new BlogPostDto
|
var linkUpdate = new BlogPostDto
|
||||||
{
|
{
|
||||||
|
|
@ -304,22 +211,16 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
DateCreated = created.DateCreated,
|
DateCreated = created.DateCreated,
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
savedTitle = linkUpdate.Title;
|
|
||||||
savedArticle = linkUpdate.Article;
|
|
||||||
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
|
await BlogClient.UpdatePostAsync(created.Id, linkUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
created.Title = savedTitle;
|
|
||||||
created.Article = savedArticle;
|
|
||||||
this.SetInfoStatus($"Billet {created.Id} créé.");
|
this.SetInfoStatus($"Billet {created.Id} créé.");
|
||||||
DraftAttachments.Clear();
|
DraftAttachments.Clear();
|
||||||
MarkDraftSaved(savedTitle, savedArticle);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
throw new InvalidOperationException("Le serveur n'a pas retourné le billet créé.");
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
TryAppendAttachmentLinks(SelectedPost, attachments);
|
||||||
var update = new BlogPostDto
|
var update = new BlogPostDto
|
||||||
{
|
{
|
||||||
Id = SelectedPost.Id,
|
Id = SelectedPost.Id,
|
||||||
|
|
@ -331,15 +232,11 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
DateModified = DateTime.UtcNow,
|
DateModified = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
|
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
|
||||||
SelectedPost.Title = update.Title;
|
|
||||||
SelectedPost.Article = update.Article;
|
|
||||||
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
|
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
|
||||||
DraftAttachments.Clear();
|
DraftAttachments.Clear();
|
||||||
MarkDraftSaved(update.Title, update.Article);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await RefreshPostsAsync();
|
await RefreshPostsAsync();
|
||||||
UpdateReader();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,7 +429,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
Posts = new ObservableCollection<BlogPostDto>();
|
Posts = new ObservableCollection<BlogPostDto>();
|
||||||
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
||||||
DraftAttachments = new ObservableCollection<BlogUploadFile>();
|
DraftAttachments = new ObservableCollection<BlogUploadFile>();
|
||||||
DraftAttachments.CollectionChanged += DraftAttachments_CollectionChanged;
|
|
||||||
SelectedPost = null;
|
SelectedPost = null;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
this.SetInfoStatus("Prêt.");
|
this.SetInfoStatus("Prêt.");
|
||||||
|
|
@ -544,7 +440,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
DraftArticle = string.Empty;
|
DraftArticle = string.Empty;
|
||||||
|
|
||||||
DraftIsPublished = false;
|
DraftIsPublished = false;
|
||||||
MarkDraftSaved();
|
|
||||||
IsLoaded = false;
|
IsLoaded = false;
|
||||||
// Production path: DI injects the canonical Settings singleton
|
// Production path: DI injects the canonical Settings singleton
|
||||||
// and we use it as-is. Test path: tests call this constructor
|
// and we use it as-is. Test path: tests call this constructor
|
||||||
|
|
@ -585,14 +480,12 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
/// <see cref="BlogApiClient"/>. Production code uses the
|
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||||
/// (Settings, BlogApiClient) overload below.
|
/// (Settings, BlogApiClient) overload below.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null,
|
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
|
||||||
BlogResourceService? resources = null)
|
|
||||||
{
|
{
|
||||||
WindowTitle = "Blog";
|
WindowTitle = "Blog";
|
||||||
SettingsModel = new Settings();
|
SettingsModel = new Settings();
|
||||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||||
Services = services;
|
Services = services;
|
||||||
_resources = resources ?? new BlogResourceService();
|
|
||||||
Init(settings);
|
Init(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -607,10 +500,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
|
|
||||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||||
{
|
{
|
||||||
UpdateReader();
|
|
||||||
if (!_preserveDraft)
|
|
||||||
IsEditing = false;
|
|
||||||
if (_preserveDraft) return;
|
|
||||||
// Mirror the selection into the editor buffer so the
|
// Mirror the selection into the editor buffer so the
|
||||||
// XAML-bound TextBox/TextEditor show the right content
|
// XAML-bound TextBox/TextEditor show the right content
|
||||||
// when the user clicks a post in the list. When the
|
// when the user clicks a post in the list. When the
|
||||||
|
|
@ -624,59 +513,15 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
// null selection so a fresh draft starts unpublished.
|
// null selection so a fresh draft starts unpublished.
|
||||||
DraftIsPublished = value?.IsPublished ?? false;
|
DraftIsPublished = value?.IsPublished ?? false;
|
||||||
DraftAttachments.Clear();
|
DraftAttachments.Clear();
|
||||||
_pendingAttachmentLinks.Clear();
|
|
||||||
MarkDraftSaved();
|
|
||||||
UpdateCommandStates();
|
UpdateCommandStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateReader()
|
|
||||||
{
|
|
||||||
string? baseUrl = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (SelectedPost is not null)
|
|
||||||
baseUrl = BlogResourceService.GetUserBaseUri(
|
|
||||||
Settings.Authentication.Authority, ReaderAuthor).AbsoluteUri;
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or UriFormatException)
|
|
||||||
{
|
|
||||||
this.SetErrorStatus($"Impossible de résoudre les ressources du billet : {ex.Message}");
|
|
||||||
}
|
|
||||||
ReaderBaseUrl = baseUrl;
|
|
||||||
OnPropertyChanged(nameof(SelectedPost));
|
|
||||||
OnPropertyChanged(nameof(ReaderArticle));
|
|
||||||
OnPropertyChanged(nameof(ReaderAuthor));
|
|
||||||
OnPropertyChanged(nameof(ReaderTitle));
|
|
||||||
OnPropertyChanged(nameof(ReaderDateModified));
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
|
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
|
||||||
|
|
||||||
// Save's CanExecute depends on the buffer: the button must
|
// Save's CanExecute depends on the buffer: the button must
|
||||||
// enable as soon as the user has typed a non-whitespace
|
// enable as soon as the user has typed a non-whitespace
|
||||||
// title, regardless of whether a post is selected.
|
// title, regardless of whether a post is selected.
|
||||||
partial void OnDraftTitleChanged(string value)
|
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
{
|
|
||||||
SaveCommand.NotifyCanExecuteChanged();
|
|
||||||
UpdateDraftModified();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateDraftModified()
|
|
||||||
{
|
|
||||||
IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal)
|
|
||||||
|| !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal)
|
|
||||||
|| (DraftAttachments?.Count ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MarkDraftSaved()
|
|
||||||
=> MarkDraftSaved(DraftTitle, DraftArticle);
|
|
||||||
|
|
||||||
private void MarkDraftSaved(string? title, string? article)
|
|
||||||
{
|
|
||||||
_savedTitle = title ?? string.Empty;
|
|
||||||
_savedArticle = article ?? string.Empty;
|
|
||||||
UpdateDraftModified();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private async Task RefreshPostsAsync()
|
private async Task RefreshPostsAsync()
|
||||||
|
|
@ -689,6 +534,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
}
|
}
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
|
|
||||||
|
if (SelectedPost is not null)
|
||||||
|
{
|
||||||
|
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyFilter()
|
private void ApplyFilter()
|
||||||
|
|
@ -703,29 +552,10 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
|
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
|
||||||
.OrderByDescending(p => p.DateModified);
|
.OrderByDescending(p => p.DateModified);
|
||||||
|
|
||||||
var selection = SelectedPost;
|
FilteredPosts.Clear();
|
||||||
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray();
|
foreach (var post in filtered)
|
||||||
_preserveDraft = true;
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
for (var i = FilteredPosts.Count - 1; i >= 0; i--)
|
FilteredPosts.Add(post);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -764,53 +594,40 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
|
||||||
{
|
{
|
||||||
if (e.NewItems is not null)
|
if (attachments.Count == 0)
|
||||||
{
|
return false;
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string AttachmentUrl(BlogPostDto post, string storedName)
|
var ownerSegment = post.Author?.UserName;
|
||||||
{
|
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||||
var owner = post.Author?.UserName ?? post.AuthorId;
|
ownerSegment = post.AuthorId;
|
||||||
if (string.IsNullOrWhiteSpace(owner))
|
|
||||||
throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier.");
|
if (string.IsNullOrWhiteSpace(ownerSegment))
|
||||||
return ResolveUserFileUrl(
|
return false;
|
||||||
$"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ResolvePendingAttachmentLinks(BlogPostDto post)
|
|
||||||
{
|
|
||||||
var article = DraftArticle ?? string.Empty;
|
var article = DraftArticle ?? string.Empty;
|
||||||
foreach (var link in _pendingAttachmentLinks)
|
var links = new List<string>();
|
||||||
article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
|
|
||||||
StringComparison.Ordinal);
|
foreach (var attachment in attachments)
|
||||||
var changed = article != DraftArticle;
|
{
|
||||||
if (changed) DraftArticle = article;
|
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
|
||||||
_pendingAttachmentLinks.Clear();
|
var fileUrl = ResolveUserFileUrl(relativePath);
|
||||||
return changed;
|
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
|
||||||
|
|
||||||
|
if (!article.Contains(markdownLine, StringComparison.Ordinal))
|
||||||
|
links.Add(markdownLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (links.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var prefix = article.Length == 0
|
||||||
|
? ""
|
||||||
|
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
||||||
|
|
||||||
|
DraftArticle = article + prefix + string.Join("\n", links);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveUserFileUrl(string relativePath)
|
private string ResolveUserFileUrl(string relativePath)
|
||||||
|
|
@ -819,7 +636,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
if (!string.IsNullOrWhiteSpace(authority)
|
if (!string.IsNullOrWhiteSpace(authority)
|
||||||
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
|
&& Uri.TryCreate(authority, UriKind.Absolute, out var baseUri))
|
||||||
{
|
{
|
||||||
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri;
|
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
|
||||||
|
|
||||||
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
|
|
||||||
{
|
|
||||||
public Uri Source { get; } = source;
|
|
||||||
public string MimeType { get; } = mimeType;
|
|
||||||
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
|
||||||
public override bool CanNavigateNext { get => false; protected set { } }
|
|
||||||
public override bool CanNavigatePrevious { get => true; protected set { } }
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Threading;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels.Chat;
|
using PostIt.ViewModels.Chat;
|
||||||
using Yavsc.Abstract.Models.Messaging;
|
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
public class HomePageViewModel : ViewModelBase
|
public class HomePageViewModel : ViewModelBase
|
||||||
|
|
@ -18,26 +13,6 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public YavscApiClient Api { get; }
|
public YavscApiClient Api { get; }
|
||||||
public Settings Settings { get; }
|
public Settings Settings { get; }
|
||||||
public SessionStatusViewModel SessionStatus { 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!";
|
private string _welcomeText = "Welcome to PostIt!";
|
||||||
public string WelcomeText
|
public string WelcomeText
|
||||||
|
|
@ -49,16 +24,11 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
|
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 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;
|
Api = api;
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
SessionStatus = sessionStatus;
|
SessionStatus = sessionStatus;
|
||||||
_notificationsClient = notificationsClient;
|
|
||||||
SessionStatus.PropertyChanged += OnSessionStatusChanged;
|
|
||||||
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
|
|
||||||
() => _notificationsClient is not null);
|
|
||||||
|
|
||||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||||
|
|
@ -85,59 +55,6 @@ public class HomePageViewModel : ViewModelBase
|
||||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenMyFiles { 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()
|
private async Task OpenActivitiesAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -311,7 +228,7 @@ public class HomePageViewModel : ViewModelBase
|
||||||
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
||||||
/// designer-only duplicate instance is therefore harmless.
|
/// designer-only duplicate instance is therefore harmless.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
|
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
x:Class="PostIt.Views.Blogs.BlogsPage"
|
x:Class="PostIt.Views.Blogs.BlogsPage"
|
||||||
xmlns="https://github.com/avaloniaui"
|
xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||||
xmlns:services="using:PostIt.Services"
|
|
||||||
xmlns:models="using:Yavsc.Blogspot"
|
xmlns:models="using:Yavsc.Blogspot"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
|
xmlns:views="using:PostIt.Views.Blogs"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
|
|
@ -16,15 +17,8 @@
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:BlogsViewModel />
|
<vm:BlogsViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<ContentPage.Styles>
|
|
||||||
<Style Selector="CommandBarButton.modified">
|
|
||||||
<Setter Property="Background" Value="#FFF0C2" />
|
|
||||||
<Setter Property="Foreground" Value="#663C00" />
|
|
||||||
<Setter Property="FontWeight" Value="Bold" />
|
|
||||||
</Style>
|
|
||||||
</ContentPage.Styles>
|
|
||||||
<Grid
|
<Grid
|
||||||
RowDefinitions="Auto,Auto,*,Auto"
|
RowDefinitions="Auto,Auto,*"
|
||||||
Margin="5,5,10,5"
|
Margin="5,5,10,5"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch">
|
VerticalAlignment="Stretch">
|
||||||
|
|
@ -35,48 +29,37 @@
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="20"
|
FontSize="20"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="Billets de blog" />
|
Text="Blog Posts" />
|
||||||
<TextBox
|
<TextBox
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
PlaceholderText="Rechercher un titre ou un auteur..."
|
PlaceholderText="Filter by title or author..."
|
||||||
Text="{Binding SearchText, Mode=TwoWay}" />
|
Text="{Binding SearchText, Mode=TwoWay}" />
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
<CommandBar x:Name="BlogsCommandBar"
|
<CommandBar x:Name="BlogsCommandBar"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
||||||
<CommandBar.PrimaryCommands>
|
<CommandBar.PrimaryCommands>
|
||||||
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
|
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
|
||||||
<CommandBarButton.Icon>
|
<CommandBarButton.Icon>
|
||||||
<PathIcon Data="{StaticResource RefreshIcon}" />
|
<PathIcon Data="{StaticResource RefreshIcon}" />
|
||||||
</CommandBarButton.Icon>
|
</CommandBarButton.Icon>
|
||||||
</CommandBarButton>
|
</CommandBarButton>
|
||||||
<CommandBarButton x:Name="NewPostButton" Command="{Binding NewPostCommand}" Label="Nouveau billet" />
|
|
||||||
<CommandBarButton x:Name="ReadPostButton" Command="{Binding ReadPostCommand}"
|
|
||||||
IsVisible="{Binding IsEditing}" Label="Lire" />
|
|
||||||
<CommandBarButton x:Name="EditPostButton" Command="{Binding EditPostCommand}"
|
|
||||||
IsEnabled="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"
|
|
||||||
IsVisible="{Binding !IsEditing}" Label="Modifier" />
|
|
||||||
<CommandBarButton
|
<CommandBarButton
|
||||||
x:Name="SaveButton"
|
x:Name="SaveButton"
|
||||||
IsVisible="{Binding IsEditing}"
|
Command="{Binding SaveAsync}"
|
||||||
Command="{Binding SaveCommand}"
|
|
||||||
Classes.modified="{Binding IsDraftModified}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
Label="Save">
|
Label="Save">
|
||||||
<CommandBarButton.Icon>
|
<CommandBarButton.Icon>
|
||||||
<PathIcon Data="{StaticResource SaveIcon}" />
|
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||||
</CommandBarButton.Icon>
|
</CommandBarButton.Icon>
|
||||||
</CommandBarButton>
|
</CommandBarButton>
|
||||||
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
|
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
|
||||||
<CommandBarButton.Icon>
|
<CommandBarButton.Icon>
|
||||||
<PathIcon Data="{StaticResource DeleteIcon}" />
|
<PathIcon Data="{StaticResource DeleteIcon}" />
|
||||||
</CommandBarButton.Icon>
|
</CommandBarButton.Icon>
|
||||||
</CommandBarButton>
|
</CommandBarButton>
|
||||||
<CommandBarButton
|
<CommandBarButton
|
||||||
x:Name="AddAttachmentButton"
|
x:Name="AddAttachmentButton"
|
||||||
IsVisible="{Binding IsEditing}"
|
|
||||||
Click="AddAttachment_Click"
|
Click="AddAttachment_Click"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
Label="Ajouter fichiers">
|
Label="Ajouter fichiers">
|
||||||
<CommandBarButton.Icon>
|
<CommandBarButton.Icon>
|
||||||
<PathIcon Data="{StaticResource AddIcon}" />
|
<PathIcon Data="{StaticResource AddIcon}" />
|
||||||
|
|
@ -84,7 +67,6 @@
|
||||||
</CommandBarButton>
|
</CommandBarButton>
|
||||||
<CommandBarButton
|
<CommandBarButton
|
||||||
x:Name="ManageAclButton"
|
x:Name="ManageAclButton"
|
||||||
IsVisible="{Binding IsEditing}"
|
|
||||||
Command="{Binding ManageAclAsync}"
|
Command="{Binding ManageAclAsync}"
|
||||||
Label="ACL">
|
Label="ACL">
|
||||||
<CommandBarButton.Icon>
|
<CommandBarButton.Icon>
|
||||||
|
|
@ -100,7 +82,6 @@
|
||||||
</CommandBarButton.Icon>
|
</CommandBarButton.Icon>
|
||||||
</CommandBarButton>
|
</CommandBarButton>
|
||||||
<CommandBarToggleButton
|
<CommandBarToggleButton
|
||||||
IsVisible="{Binding IsEditing}"
|
|
||||||
Command="{Binding TogglePublishCommand}"
|
Command="{Binding TogglePublishCommand}"
|
||||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
||||||
Label="Publié">
|
Label="Publié">
|
||||||
|
|
@ -108,14 +89,13 @@
|
||||||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||||
</CommandBarToggleButton.Icon>
|
</CommandBarToggleButton.Icon>
|
||||||
</CommandBarToggleButton>
|
</CommandBarToggleButton>
|
||||||
<CommandBarToggleButton IsVisible="{Binding IsEditing}" IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
||||||
<CommandBarToggleButton.Icon>
|
<CommandBarToggleButton.Icon>
|
||||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||||
</CommandBarToggleButton.Icon>
|
</CommandBarToggleButton.Icon>
|
||||||
</CommandBarToggleButton>
|
</CommandBarToggleButton>
|
||||||
<CommandBarButton
|
<CommandBarButton
|
||||||
x:Name="OpenSignatureDevButton"
|
x:Name="OpenSignatureDevButton"
|
||||||
IsVisible="{Binding IsEditing}"
|
|
||||||
Command="{Binding OpenSignatureDevAsync}"
|
Command="{Binding OpenSignatureDevAsync}"
|
||||||
Label="[DEV] Signature">
|
Label="[DEV] Signature">
|
||||||
<CommandBarButton.Icon>
|
<CommandBarButton.Icon>
|
||||||
|
|
@ -138,7 +118,6 @@
|
||||||
BorderThickness="1">
|
BorderThickness="1">
|
||||||
<ListBox
|
<ListBox
|
||||||
x:Name="PostsListBox"
|
x:Name="PostsListBox"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="models:BlogPostDto">
|
<DataTemplate x:DataType="models:BlogPostDto">
|
||||||
|
|
@ -183,8 +162,7 @@
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
ResizeDirection="Rows" />
|
ResizeDirection="Rows" />
|
||||||
|
|
||||||
<Grid x:Name="PostEditor"
|
<Grid
|
||||||
IsVisible="{Binding IsEditing}"
|
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
|
|
@ -194,6 +172,7 @@
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
|
|
@ -236,34 +215,9 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||||
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
|
|
||||||
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
|
|
||||||
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
<WrapPanel Grid.Row="1" Orientation="Horizontal"
|
|
||||||
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
|
||||||
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
|
|
||||||
<TextBlock Text="{Binding ReaderDateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
|
|
||||||
Opacity="0.65" />
|
|
||||||
</WrapPanel>
|
|
||||||
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
|
|
||||||
AssetPathRoot="{Binding ReaderBaseUrl}"
|
|
||||||
Markdown="{Binding ReaderArticle}"
|
|
||||||
SaveScrollValueWhenContentUpdated="False">
|
|
||||||
<reader:MarkdownScrollViewer.Plugins>
|
|
||||||
<reader:MdAvPlugins>
|
|
||||||
<reader:MdAvPlugins.PathResolver>
|
|
||||||
<services:BlogImageResolver />
|
|
||||||
</reader:MdAvPlugins.PathResolver>
|
|
||||||
</reader:MdAvPlugins>
|
|
||||||
</reader:MarkdownScrollViewer.Plugins>
|
|
||||||
</reader:MarkdownScrollViewer>
|
|
||||||
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
|
|
||||||
Text="Choisissez un billet dans la liste pour le lire."
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -4,40 +4,25 @@ using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Markdown.Avalonia;
|
|
||||||
|
|
||||||
namespace PostIt.Views.Blogs;
|
namespace PostIt.Views.Blogs;
|
||||||
|
|
||||||
public partial class BlogsPage : ContentPage
|
public partial class BlogsPage : ContentPage
|
||||||
{
|
{
|
||||||
private static readonly RelayCommand<string?> DisabledLinkCommand =
|
|
||||||
new(static _ => { }, static _ => false);
|
|
||||||
|
|
||||||
public BlogsPage()
|
public BlogsPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContextChanged += (_, _) => UpdateHyperlinkCommand();
|
|
||||||
UpdateHyperlinkCommand();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateHyperlinkCommand()
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||||
{
|
|
||||||
if (ArticleReader.Engine is IMarkdownEngine2 engine)
|
|
||||||
{
|
|
||||||
if (DataContext is ViewModels.BlogsViewModel vm)
|
|
||||||
engine.HyperlinkCommand = vm.OpenResourceCommand;
|
|
||||||
else
|
|
||||||
engine.HyperlinkCommand = DisabledLinkCommand;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)
|
|
||||||
{
|
{
|
||||||
base.OnApplyTemplate(e);
|
base.OnApplyTemplate(e);
|
||||||
if (DataContext is ViewModels.BlogsViewModel vm)
|
if (DataContext is ViewModels.BlogsViewModel vm)
|
||||||
{
|
{
|
||||||
await vm.InitializeAsync();
|
if (!vm.IsLoaded)
|
||||||
|
{
|
||||||
|
vm.RefreshAsync().Wait();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +50,7 @@ public partial class BlogsPage : ContentPage
|
||||||
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vm.DraftAttachments.Clear();
|
||||||
foreach (var upload in uploads)
|
foreach (var upload in uploads)
|
||||||
vm.DraftAttachments.Add(upload);
|
vm.DraftAttachments.Add(upload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<ContentPage
|
|
||||||
x:Class="PostIt.Views.Blogs.VideoPage"
|
|
||||||
xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
x:DataType="vm:VideoPageViewModel">
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
|
||||||
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
|
||||||
<Border Grid.Row="1" Background="Black">
|
|
||||||
<vlc:VideoView x:Name="Video" />
|
|
||||||
</Border>
|
|
||||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
|
||||||
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
|
||||||
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
|
|
||||||
</Grid>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
using System;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using LibVLCSharp.Shared;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views.Blogs;
|
|
||||||
|
|
||||||
public partial class VideoPage : ContentPage
|
|
||||||
{
|
|
||||||
private LibVLC? _libVlc;
|
|
||||||
private MediaPlayer? _player;
|
|
||||||
private Media? _media;
|
|
||||||
|
|
||||||
public VideoPage() => InitializeComponent();
|
|
||||||
|
|
||||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(e);
|
|
||||||
ReleasePlayer();
|
|
||||||
if (DataContext is not VideoPageViewModel vm)
|
|
||||||
return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Core.Initialize();
|
|
||||||
_libVlc = new LibVLC();
|
|
||||||
_player = new MediaPlayer(_libVlc);
|
|
||||||
_player.EncounteredError += Player_EncounteredError;
|
|
||||||
_media = new Media(_libVlc, vm.Source);
|
|
||||||
Video.MediaPlayer = _player;
|
|
||||||
PlayButton.IsEnabled = true;
|
|
||||||
PlaybackStatus.Text = vm.MimeType;
|
|
||||||
if (!_player.Play(_media))
|
|
||||||
PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
|
|
||||||
or BadImageFormatException or PlatformNotSupportedException)
|
|
||||||
{
|
|
||||||
ReleasePlayer();
|
|
||||||
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
|
||||||
{
|
|
||||||
ReleasePlayer();
|
|
||||||
base.OnNavigatedFrom(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
|
||||||
{
|
|
||||||
ReleasePlayer();
|
|
||||||
base.OnDetachedFromVisualTree(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReleasePlayer()
|
|
||||||
{
|
|
||||||
PlayButton.IsEnabled = false;
|
|
||||||
Video.MediaPlayer = null;
|
|
||||||
if (_player is not null)
|
|
||||||
{
|
|
||||||
_player.EncounteredError -= Player_EncounteredError;
|
|
||||||
_player.Stop();
|
|
||||||
_player.Dispose();
|
|
||||||
_player = null;
|
|
||||||
}
|
|
||||||
_media?.Dispose();
|
|
||||||
_media = null;
|
|
||||||
_libVlc?.Dispose();
|
|
||||||
_libVlc = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Player_EncounteredError(object? sender, EventArgs e)
|
|
||||||
=> Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(sender, _player))
|
|
||||||
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
|
||||||
});
|
|
||||||
|
|
||||||
private void PlayPause_Click(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_player is null || _media is null)
|
|
||||||
return;
|
|
||||||
if (_player.IsPlaying)
|
|
||||||
_player.Pause();
|
|
||||||
else if (!_player.Play())
|
|
||||||
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not VideoPageViewModel vm)
|
|
||||||
return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var topLevel = TopLevel.GetTopLevel(this)
|
|
||||||
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
|
||||||
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
|
||||||
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,50 +2,20 @@
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
xmlns:lang="using:Yavsc.Lang"
|
xmlns:lang="using:Yavsc.Lang"
|
||||||
xmlns:messaging="using:Yavsc.Abstract.Models.Messaging"
|
|
||||||
x:Class="PostIt.Views.HomePage"
|
x:Class="PostIt.Views.HomePage"
|
||||||
x:DataType="vm:HomePageViewModel"
|
x:DataType="vm:HomePageViewModel"
|
||||||
Header="Home">
|
Header="Home">
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:HomePageViewModel />
|
<vm:HomePageViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
<StackPanel HorizontalAlignment="Center"
|
||||||
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
VerticalAlignment="Center"
|
||||||
HorizontalAlignment="Stretch">
|
Spacing="12">
|
||||||
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
<TextBlock Text="{x:Static lang:Resources.GreetingText}"
|
||||||
FontSize="22"
|
FontSize="22"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
HorizontalAlignment="Center"/>
|
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"
|
<Button Content="Les Salons de discussion"
|
||||||
Command="{Binding OpenChat}"
|
Command="{Binding OpenChat}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
|
|
@ -91,5 +61,4 @@
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -9,12 +8,4 @@ public partial class HomePage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(e);
|
|
||||||
if (!Design.IsDesignMode && DataContext is HomePageViewModel vm
|
|
||||||
&& vm.RefreshNotificationsCommand.CanExecute(null))
|
|
||||||
_ = vm.RefreshNotificationsAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ namespace Yavsc
|
||||||
public const string ResourceUsagePath = "resource-usage";
|
public const string ResourceUsagePath = "resource-usage";
|
||||||
public const string ResourceUsageCurrentUserRoute = "me";
|
public const string ResourceUsageCurrentUserRoute = "me";
|
||||||
public const string ResourceUsageAdminRoute = "admin";
|
public const string ResourceUsageAdminRoute = "admin";
|
||||||
public const string NotificationsPath = "notifications";
|
|
||||||
public const string NotificationsCurrentUserRoute = "me";
|
|
||||||
public const string ResourceUsageDefaultCurrency = "EUR";
|
public const string ResourceUsageDefaultCurrency = "EUR";
|
||||||
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
public const string ResourceUsageCurrentUserDisplayName = "current-user";
|
||||||
public const string ResourceUsageAdminDisplayName = "administrator";
|
public const string ResourceUsageAdminDisplayName = "administrator";
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ namespace Yavsc.Server.Helpers
|
||||||
// Ensure this path is canonical,
|
// Ensure this path is canonical,
|
||||||
// No "dirto/./this", neither "dirt/to/that/"
|
// No "dirto/./this", neither "dirt/to/that/"
|
||||||
// no .. and each char must be listed as valid in constants
|
// no .. and each char must be listed as valid in constants
|
||||||
// Encode unsupported chars with '_' rather than the URL fragment marker '#'.
|
|
||||||
public static string FilterFileName(string fileName)
|
public static string FilterFileName(string fileName)
|
||||||
{
|
{
|
||||||
if (fileName==null) return null;
|
if (fileName==null) return null;
|
||||||
|
|
@ -58,7 +57,7 @@ namespace Yavsc.Server.Helpers
|
||||||
{
|
{
|
||||||
if (ValidFileNameChars.Contains(c))
|
if (ValidFileNameChars.Contains(c))
|
||||||
sb.Append(c);
|
sb.Append(c);
|
||||||
else sb.Append("_" + ((int)c).ToString("D3"));
|
else sb.Append("#" + ((int)c).ToString("D3"));
|
||||||
}
|
}
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,6 @@ public interface IYavscApiClient : IAsyncDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
HttpClient Http { get; }
|
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>
|
/// <summary>Call a JSON endpoint with a typed return value.</summary>
|
||||||
/// <param name="method">HTTP verb.</param>
|
/// <param name="method">HTTP verb.</param>
|
||||||
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -322,8 +322,6 @@ namespace Yavsc.ApiControllers
|
||||||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
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 });
|
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,9 +382,12 @@ namespace Yavsc.ApiControllers
|
||||||
.FirstOrDefaultAsync(e => e.Id == id, token);
|
.FirstOrDefaultAsync(e => e.Id == id, token);
|
||||||
if (estimate is null) return NotFound(new { Error = "estimate not found" });
|
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;
|
var userId = body.SignerUserId;
|
||||||
if (userId != User.GetUserId())
|
|
||||||
return Forbid();
|
|
||||||
if (userId != estimate.OwnerId && userId != estimate.ClientId)
|
if (userId != estimate.OwnerId && userId != estimate.ClientId)
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
|
|
@ -413,9 +414,6 @@ namespace Yavsc.ApiControllers
|
||||||
var signature = await EstimateSignaturePersister.StageAsync(
|
var signature = await EstimateSignaturePersister.StageAsync(
|
||||||
dbContext, id, type, userId, payload, token);
|
dbContext, id, type, userId, payload, token);
|
||||||
|
|
||||||
if (type == SignatureType.Client)
|
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await dbContext.SaveChangesAsync(token);
|
await dbContext.SaveChangesAsync(token);
|
||||||
|
|
@ -426,12 +424,6 @@ namespace Yavsc.ApiControllers
|
||||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
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 })
|
var location = Url.Action(nameof(Sign), new { id })
|
||||||
?? $"/api/bill/estimate/{id}/sign";
|
?? $"/api/bill/estimate/{id}/sign";
|
||||||
return Created(location, new
|
return Created(location, new
|
||||||
|
|
@ -449,8 +441,9 @@ namespace Yavsc.ApiControllers
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
|
/// JSON body of <c>POST /api/bill/estimate/{id}/sign</c>. The
|
||||||
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
|
/// shape mirrors what PostIt sends; the <c>signerUserId</c>
|
||||||
/// field must match the authenticated caller and identifies
|
/// field disambiguates which side of the estimate signed
|
||||||
/// which party of the estimate signed.
|
/// because the bearer token belongs to the PostIt OAuth
|
||||||
|
/// client, not the end user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SignatureSubmission
|
public class SignatureSubmission
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
#nullable enable annotations
|
|
||||||
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
@ -12,7 +10,6 @@ using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Models.Messaging;
|
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.ViewModels.FrontOffice;
|
using Yavsc.ViewModels.FrontOffice;
|
||||||
|
|
@ -29,7 +26,6 @@ namespace Yavsc.ApiControllers
|
||||||
private readonly ILogger logger;
|
private readonly ILogger logger;
|
||||||
private readonly SiteSettings siteSettings;
|
private readonly SiteSettings siteSettings;
|
||||||
private readonly IRazorViewEngine viewEngine;
|
private readonly IRazorViewEngine viewEngine;
|
||||||
private readonly IYavscMessageSender messageSender;
|
|
||||||
|
|
||||||
// The view-rendering dependencies (siteSettings, viewEngine) are
|
// The view-rendering dependencies (siteSettings, viewEngine) are
|
||||||
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
||||||
|
|
@ -49,13 +45,11 @@ namespace Yavsc.ApiControllers
|
||||||
public FrontOfficeApiController(
|
public FrontOfficeApiController(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IYavscMessageSender messageSender,
|
|
||||||
IOptions<SiteSettings> siteSettings = null,
|
IOptions<SiteSettings> siteSettings = null,
|
||||||
IRazorViewEngine viewEngine = null,
|
IRazorViewEngine viewEngine = null,
|
||||||
IBillingService billing = null)
|
IBillingService billing = null)
|
||||||
{
|
{
|
||||||
dbContext = context;
|
dbContext = context;
|
||||||
this.messageSender = messageSender;
|
|
||||||
this.billing = billing ?? new BillingService(context);
|
this.billing = billing ?? new BillingService(context);
|
||||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
||||||
this.siteSettings = siteSettings?.Value;
|
this.siteSettings = siteSettings?.Value;
|
||||||
|
|
@ -239,7 +233,6 @@ namespace Yavsc.ApiControllers
|
||||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
Signature? signature = null;
|
Signature? signature = null;
|
||||||
ClientEstimateSignedEvent? signedEvent = null;
|
|
||||||
// actingRole is the role the caller is signing / accepting
|
// actingRole is the role the caller is signing / accepting
|
||||||
// as. It defaults to the role inferred from the caller's
|
// as. It defaults to the role inferred from the caller's
|
||||||
// identity, but when a signature is submitted the caller
|
// identity, but when a signature is submitted the caller
|
||||||
|
|
@ -306,11 +299,7 @@ namespace Yavsc.ApiControllers
|
||||||
if (actingRole == ActorRole.Provider)
|
if (actingRole == ActorRole.Provider)
|
||||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||||
else
|
else
|
||||||
{
|
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
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
|
// The party-specific status records who accepted: the
|
||||||
|
|
@ -334,9 +323,6 @@ namespace Yavsc.ApiControllers
|
||||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
queryId,
|
queryId,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ using Yavsc.Models.Billing;
|
||||||
using Yavsc.Models.Relationship;
|
using Yavsc.Models.Relationship;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.Helpers;
|
|
||||||
using Yavsc.Services;
|
|
||||||
|
|
||||||
namespace Yavsc.Controllers;
|
namespace Yavsc.Controllers;
|
||||||
|
|
||||||
|
|
@ -21,12 +19,10 @@ namespace Yavsc.Controllers;
|
||||||
public class RdvQueryApiController : Controller
|
public class RdvQueryApiController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
private readonly IYavscMessageSender _messageSender;
|
|
||||||
|
|
||||||
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
|
public RdvQueryApiController(ApplicationDbContext context)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_messageSender = messageSender;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -97,16 +93,6 @@ public class RdvQueryApiController : Controller
|
||||||
return BadRequest(new { Error = "location is required" });
|
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);
|
var resolvedLocation = await ResolveLocationAsync(query.Location, cancellationToken);
|
||||||
if (resolvedLocation is null)
|
if (resolvedLocation is null)
|
||||||
{
|
{
|
||||||
|
|
@ -138,9 +124,6 @@ public class RdvQueryApiController : Controller
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (performer.AcceptNotifications && performer.AcceptPublicContact)
|
|
||||||
await _messageSender.NotifyBookQueryAsync(new[] { performer.PerformerId }, query.CreateEvent("NewCommand"));
|
|
||||||
|
|
||||||
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,11 @@ internal class Program
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
|
|
||||||
|
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
||||||
app
|
app
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
.UseAuthentication()
|
.UseAuthentication()
|
||||||
.UseAuthorization()
|
.UseAuthorization()
|
||||||
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
|
||||||
.UseCors("default");
|
.UseCors("default");
|
||||||
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("ApiScope");
|
||||||
app.MapDefaultControllerRoute();
|
app.MapDefaultControllerRoute();
|
||||||
|
|
|
||||||
8
src/Yavsc.Blogs/Constants.cs
Normal file
8
src/Yavsc.Blogs/Constants.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Yavsc.Blogs;
|
||||||
|
|
||||||
|
public static class BlogConstants
|
||||||
|
{
|
||||||
|
public const string AdminRole = "Admin";
|
||||||
|
public const string ModeratorRole = "Moderator";
|
||||||
|
public const string UserRole = "User";
|
||||||
|
}
|
||||||
|
|
@ -200,7 +200,7 @@ namespace Yavsc.Blogs.Controllers
|
||||||
if (string.IsNullOrEmpty(ownerUserName))
|
if (string.IsNullOrEmpty(ownerUserName))
|
||||||
return NotFound(new { error = "owner not found" });
|
return NotFound(new { error = "owner not found" });
|
||||||
|
|
||||||
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, ownerUserName, file.Path ?? "");
|
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||||
var fi = new FileInfo(physicalPath);
|
var fi = new FileInfo(physicalPath);
|
||||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Yavsc.Models;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
namespace Yavsc.Blogs;
|
namespace Yavsc.Blogs;
|
||||||
|
|
||||||
internal class Program
|
internal class Program
|
||||||
|
|
@ -84,9 +85,9 @@ internal class Program
|
||||||
})
|
})
|
||||||
.Configure<SiteSettings>(settings =>
|
.Configure<SiteSettings>(settings =>
|
||||||
{
|
{
|
||||||
if (settings.UserFilesRoot == null)
|
if (settings.Blog == null)
|
||||||
{
|
{
|
||||||
settings.UserFilesRoot = "blog";
|
settings.Blog = "blog";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -97,11 +98,11 @@ internal class Program
|
||||||
{
|
{
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseDeveloperExceptionPage();
|
app.UseDeveloperExceptionPage();
|
||||||
|
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
|
||||||
app
|
app
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
.UseAuthentication()
|
.UseAuthentication()
|
||||||
.UseAuthorization()
|
.UseAuthorization()
|
||||||
.UseMiddleware<ResourceUsageTelemetryMiddleware>()
|
|
||||||
.UseCors("default")
|
.UseCors("default")
|
||||||
;
|
;
|
||||||
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
|
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"https://localhost:5005",
|
"https://localhost:5005",
|
||||||
"https://api.pschneider.fr"
|
"https://api.pschneider.fr"
|
||||||
],
|
],
|
||||||
"UserFilesRoot": "files"
|
"Blog": "blog"
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||||
|
|
|
||||||
|
|
@ -421,9 +421,9 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
// Renames the blog files
|
// Renames the blog files
|
||||||
var userdirinfo = new DirectoryInfo(
|
var userdirinfo = new DirectoryInfo(
|
||||||
Path.Combine(_siteSettings.UserFilesRoot,
|
Path.Combine(_siteSettings.Blog,
|
||||||
oldUserName));
|
oldUserName));
|
||||||
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
|
var newdir = Path.Combine(_siteSettings.Blog,
|
||||||
model.UserName);
|
model.UserName);
|
||||||
if (userdirinfo.Exists)
|
if (userdirinfo.Exists)
|
||||||
userdirinfo.MoveTo(newdir);
|
userdirinfo.MoveTo(newdir);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Yavsc.Abstract.Identity;
|
using Yavsc.Abstract.Identity;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -20,20 +19,14 @@ namespace Yavsc.Controllers
|
||||||
private readonly RoleManager<IdentityRole> _roleManager;
|
private readonly RoleManager<IdentityRole> _roleManager;
|
||||||
|
|
||||||
private readonly ApplicationDbContext _dbContext;
|
private readonly ApplicationDbContext _dbContext;
|
||||||
private readonly SiteSettings _siteSettings;
|
|
||||||
private readonly ILogger<AdministrationController> _logger;
|
|
||||||
|
|
||||||
public AdministrationController(UserManager<ApplicationUser> userManager,
|
public AdministrationController(UserManager<ApplicationUser> userManager,
|
||||||
RoleManager<IdentityRole> roleManager,
|
RoleManager<IdentityRole> roleManager,
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context)
|
||||||
IOptions<SiteSettings> siteSettings,
|
|
||||||
ILoggerFactory loggerFactory)
|
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_roleManager = roleManager;
|
_roleManager = roleManager;
|
||||||
this._dbContext = context;
|
this._dbContext = context;
|
||||||
this._siteSettings = siteSettings.Value;
|
|
||||||
this._logger = loggerFactory.CreateLogger<AdministrationController>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,16 +92,6 @@ namespace Yavsc.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _userManager.FindByIdAsync(User.GetUserId());
|
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
|
// check all user groups exist
|
||||||
if (!await EnsureRoleList())
|
if (!await EnsureRoleList())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1046,6 +1046,7 @@ public static class HostingExtensions
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
|
app.UseStaticFiles();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseIdentityServer();
|
app.UseIdentityServer();
|
||||||
|
|
@ -1266,7 +1267,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
|
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
|
||||||
}
|
}
|
||||||
if (settings.UserFilesRoot == null)
|
if (settings.Blog == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
|
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
|
||||||
}
|
}
|
||||||
|
|
@ -1283,7 +1284,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
||||||
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
|
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var userFilesDirInfo = new DirectoryInfo(settings.UserFilesRoot);
|
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
|
||||||
|
|
||||||
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,28 @@ namespace Yavsc.Helpers
|
||||||
|
|
||||||
public static class EventHelpers
|
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,
|
public static HairCutQueryEvent CreateNewHairCutQueryEvent(this HairCutQuery query,
|
||||||
IStringLocalizer<HairCutQuery> SR)
|
IStringLocalizer<HairCutQuery> SR)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ public class Startup
|
||||||
ResourcesHelpers.GlobalLocalizer = localizer;
|
ResourcesHelpers.GlobalLocalizer = localizer;
|
||||||
Config.SmtpSetup = smtpSettings.Value;
|
Config.SmtpSetup = smtpSettings.Value;
|
||||||
Config.Authority = siteSettings.Value.Authority;
|
Config.Authority = siteSettings.Value.Authority;
|
||||||
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
|
string blogsDir = siteSettings.Value.Blog ?? throw new Exception("blogsDir is not set.");
|
||||||
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir 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.");
|
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ namespace Yavsc.ViewComponents
|
||||||
{
|
{
|
||||||
string uid = ViewContext.HttpContext.User.GetUserId();
|
string uid = ViewContext.HttpContext.User.GetUserId();
|
||||||
return View(new UserDirectoryInfo(
|
return View(new UserDirectoryInfo(
|
||||||
siteSettings.UserFilesRoot,
|
siteSettings.Blog,
|
||||||
uid, dirname));
|
uid, dirname));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Quota": 200000000,
|
"Quota": 200000000,
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"UserFilesRoot": "files",
|
"Blog": "blog",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
"DataDir": "data",
|
"DataDir": "data",
|
||||||
"GitRepository": "git"
|
"GitRepository": "git"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ namespace Yavsc.Server.Helpers
|
||||||
this ClaimsPrincipal user,
|
this ClaimsPrincipal user,
|
||||||
string subpath, SiteSettings siteSettings)
|
string subpath, SiteSettings siteSettings)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.GetUserName());
|
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
|
||||||
if (!string.IsNullOrWhiteSpace(subpath))
|
if (!string.IsNullOrWhiteSpace(subpath))
|
||||||
{
|
{
|
||||||
if (!subpath.IsValidYavscPath())
|
if (!subpath.IsValidYavscPath())
|
||||||
|
|
@ -67,7 +67,7 @@ namespace Yavsc.Server.Helpers
|
||||||
|
|
||||||
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
|
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||||
var fi = new FileInfo(Path.Combine(root, fileName));
|
var fi = new FileInfo(Path.Combine(root, fileName));
|
||||||
if (!fi.Exists) return;
|
if (!fi.Exists) return;
|
||||||
// Capture the length BEFORE deleting: FileInfo.Length throws
|
// 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)
|
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||||
if (string.IsNullOrEmpty(dirName))
|
if (string.IsNullOrEmpty(dirName))
|
||||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
|
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)
|
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||||
if (string.IsNullOrEmpty(fromDirName))
|
if (string.IsNullOrEmpty(fromDirName))
|
||||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
|
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)
|
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||||
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||||
string dest;
|
string dest;
|
||||||
|
|
@ -151,7 +151,7 @@ namespace Yavsc.Server.Helpers
|
||||||
}
|
}
|
||||||
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
||||||
{
|
{
|
||||||
var root = Path.Combine(siteSettings.UserFilesRoot, user.UserName);
|
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||||
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||||
var fo = new FileInfo(Path.Combine(root, fileNameDest));
|
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)
|
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
|
||||||
{
|
{
|
||||||
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
|
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
|
||||||
return di;
|
return di;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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}.";
|
|
||||||
}
|
|
||||||
|
|
@ -36,7 +36,7 @@ public class BlogSpotService
|
||||||
_context = context;
|
_context = context;
|
||||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||||
this.siteSettings = siteSettings.Value;
|
this.siteSettings = siteSettings.Value;
|
||||||
if (siteSettings.Value.UserFilesRoot == null)
|
if (siteSettings.Value.Blog == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ public class BlogSpotService
|
||||||
{
|
{
|
||||||
string blogFilesSubdir = $"blogs/{postId}";
|
string blogFilesSubdir = $"blogs/{postId}";
|
||||||
string destDir = Path.Combine(
|
string destDir = Path.Combine(
|
||||||
siteSettings.UserFilesRoot,
|
siteSettings.Blog,
|
||||||
user.UserName,
|
user.UserName,
|
||||||
blogFilesSubdir);
|
blogFilesSubdir);
|
||||||
var di = new DirectoryInfo(destDir);
|
var di = new DirectoryInfo(destDir);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ using Yavsc.Models.Google.Messaging;
|
||||||
using Yavsc.Models.Haircut;
|
using Yavsc.Models.Haircut;
|
||||||
using Yavsc.Models.Messaging;
|
using Yavsc.Models.Messaging;
|
||||||
using Yavsc.Server.Hubs;
|
using Yavsc.Server.Hubs;
|
||||||
using Yavsc.Abstract.Models.Messaging;
|
|
||||||
|
|
||||||
namespace Yavsc.Services
|
namespace Yavsc.Services
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +30,7 @@ namespace Yavsc.Services
|
||||||
IHubContext<ChatHub> hubContext
|
IHubContext<ChatHub> hubContext
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
|
_logger = loggerFactory.CreateLogger<MailSender>();
|
||||||
_emailSender = emailSender;
|
_emailSender = emailSender;
|
||||||
siteSettings = sitesOptions?.Value;
|
siteSettings = sitesOptions?.Value;
|
||||||
this.hubContext = hubContext;
|
this.hubContext = hubContext;
|
||||||
|
|
@ -71,51 +70,34 @@ namespace Yavsc.Services
|
||||||
};
|
};
|
||||||
|
|
||||||
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
|
var user = _dbContext.Users.FirstOrDefault(u => u.Id == userId);
|
||||||
results.Add(result);
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
response.failure++;
|
response.failure++;
|
||||||
result.error = "no such user.";
|
result.error = "no such user.";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var body = ev.CreateBody();
|
if (!user.EmailConfirmed)
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
|
|
||||||
{
|
{
|
||||||
response.failure++;
|
response.failure++;
|
||||||
result.error = !user.EmailConfirmed
|
result.error = "user has not confirmed his email address.";
|
||||||
? "user has not confirmed his email address."
|
continue;
|
||||||
: "user has no legacy email address.";
|
|
||||||
_logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error);
|
|
||||||
}
|
}
|
||||||
else
|
if (user.Email == null)
|
||||||
{
|
{
|
||||||
|
response.failure++;
|
||||||
|
result.error = "user has no legacy email address.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = ev.CreateBody();
|
||||||
|
|
||||||
|
|
||||||
|
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
|
||||||
|
|
||||||
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
||||||
subject,
|
$"{ev.Sender} (un client) vous demande un rendez-vous",
|
||||||
body + Environment.NewLine);
|
body + Environment.NewLine);
|
||||||
response.success++;
|
response.success++;
|
||||||
}
|
|
||||||
|
|
||||||
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
var cxIds = _cxManager.GetConnexionIds(user.UserName);
|
||||||
if (cxIds == null)
|
if (cxIds == null)
|
||||||
|
|
@ -143,6 +125,7 @@ namespace Yavsc.Services
|
||||||
|
|
||||||
response.success++;
|
response.success++;
|
||||||
}
|
}
|
||||||
|
results.Add(result);
|
||||||
}
|
}
|
||||||
response.results = results.ToArray();
|
response.results = results.ToArray();
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ namespace Yavsc
|
||||||
public string DataDir { get; set; }
|
public string DataDir { get; set; }
|
||||||
public string Avatars { get; set; }
|
public string Avatars { get; set; }
|
||||||
public long Quota { get; set; }
|
public long Quota { get; set; }
|
||||||
public string UserFilesRoot { get; set; }
|
public string Blog { get; set; }
|
||||||
public string Bills { get; set; }
|
public string Bills { get; set; }
|
||||||
public string GitRepository { get; set; }
|
public string GitRepository { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,9 +16,6 @@ using Yavsc.Models.Relationship;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using Yavsc.Interface;
|
|
||||||
using Yavsc.Server.Hubs;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Test.Fixtures;
|
namespace Yavsc.Api.Test.Fixtures;
|
||||||
|
|
||||||
|
|
@ -67,13 +64,8 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
|
|
||||||
builder.Services.AddLocalization();
|
builder.Services.AddLocalization();
|
||||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
|
||||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||||
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
||||||
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 =>
|
builder.Services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AdministratorOnly", policy =>
|
options.AddPolicy("AdministratorOnly", policy =>
|
||||||
|
|
@ -212,9 +204,23 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
return builder.ToString();
|
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()
|
public void ResetAndSeedActivityGraph()
|
||||||
{
|
{
|
||||||
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
|
|
||||||
using var scope = Services.CreateScope();
|
using var scope = Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
|
@ -328,8 +334,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
||||||
// CommandLine reference NominativeServiceCommand (CommandId) and
|
// CommandLine reference NominativeServiceCommand (CommandId) and
|
||||||
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
||||||
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
|
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<Estimate>().RemoveRange(db.Set<Estimate>());
|
||||||
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
||||||
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
using Yavsc.Services;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
|
@ -139,8 +138,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
|
|
||||||
// Default caller is "alice" (the provider) → ProAccepted.
|
// Default caller is "alice" (the provider) → ProAccepted.
|
||||||
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
||||||
Assert.Empty(assertDb.Notification);
|
|
||||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -168,8 +165,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
Assert.Equal(SignatureType.Pro, signature.Type);
|
Assert.Equal(SignatureType.Pro, signature.Type);
|
||||||
Assert.Equal("alice", signature.SignerId);
|
Assert.Equal("alice", signature.SignerId);
|
||||||
Assert.Empty(assertDb.Notification);
|
|
||||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -197,48 +192,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||||
Assert.Equal(SignatureType.Client, signature.Type);
|
Assert.Equal(SignatureType.Client, signature.Type);
|
||||||
Assert.Equal("bob", signature.SignerId);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@ using Yavsc;
|
||||||
using Yavsc.Api.Test.Fixtures;
|
using Yavsc.Api.Test.Fixtures;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Yavsc.Models;
|
|
||||||
using Yavsc.Services;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
|
@ -37,72 +34,6 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
return http;
|
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]
|
[Fact]
|
||||||
public async Task Billing_rdv_route_supports_crud()
|
public async Task Billing_rdv_route_supports_crud()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ using System.Net.Http.Json;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Abstract.Models.Messaging;
|
|
||||||
using Yavsc.Abstract.Resources;
|
using Yavsc.Abstract.Resources;
|
||||||
using Yavsc.Api.Test.Fixtures;
|
using Yavsc.Api.Test.Fixtures;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -99,66 +98,6 @@ public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServer
|
||||||
Assert.True(record.BandwidthMb >= 3.5m);
|
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]
|
[Fact]
|
||||||
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
|
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,8 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
return file.Id;
|
return file.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("note.txt", "note.txt")]
|
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
||||||
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
|
||||||
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
|
||||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller(
|
|
||||||
string originalName, string storedName)
|
|
||||||
{
|
{
|
||||||
_fixture.ResetDatabase();
|
_fixture.ResetDatabase();
|
||||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||||
|
|
@ -109,7 +105,7 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
using var form = new MultipartFormDataContent();
|
using var form = new MultipartFormDataContent();
|
||||||
var fileContent = new ByteArrayContent(payload);
|
var fileContent = new ByteArrayContent(payload);
|
||||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||||
form.Add(fileContent, "file", originalName);
|
form.Add(fileContent, "file", "note.txt");
|
||||||
|
|
||||||
using var http = NewClient("alice");
|
using var http = NewClient("alice");
|
||||||
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||||
|
|
@ -121,17 +117,18 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||||
var fileId = item.GetProperty("fileId").GetInt64();
|
var fileId = item.GetProperty("fileId").GetInt64();
|
||||||
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
||||||
Assert.Equal(storedName, item.GetProperty("fileName").GetString());
|
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
|
||||||
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||||
|
|
||||||
using var scope = _fixture.Services.CreateScope();
|
using var scope = _fixture.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
||||||
Assert.Equal(storedName, row.Path);
|
Assert.Equal("note.txt", row.Path);
|
||||||
Assert.Equal("text/plain", row.ContentType);
|
Assert.Equal("text/plain", row.ContentType);
|
||||||
Assert.Equal(payload.Length, row.Length);
|
Assert.Equal(payload.Length, row.Length);
|
||||||
|
|
||||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)),
|
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
||||||
|
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||||
"Expected the uploaded file on disk under the owner's personal root");
|
"Expected the uploaded file on disk under the owner's personal root");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -131,7 +131,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
// on real files.
|
// on real files.
|
||||||
builder.Services.Configure<SiteSettings>(s =>
|
builder.Services.Configure<SiteSettings>(s =>
|
||||||
{
|
{
|
||||||
s.UserFilesRoot = BlogFilesRoot;
|
s.Blog = BlogFilesRoot;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Real BlogSpotService — same instance the production host
|
// Real BlogSpotService — same instance the production host
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
||||||
// BlogsWebServerFixture.
|
// BlogsWebServerFixture.
|
||||||
builder.Services.Configure<SiteSettings>(s =>
|
builder.Services.Configure<SiteSettings>(s =>
|
||||||
{
|
{
|
||||||
s.UserFilesRoot = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<BlogSpotService>();
|
builder.Services.AddScoped<BlogSpotService>();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"FavIcon": "/favicon.ico",
|
"FavIcon": "/favicon.ico",
|
||||||
"Icon": "/images/yavsc.png",
|
"Icon": "/images/yavsc.png",
|
||||||
"GitRepository": "testingrepo",
|
"GitRepository": "testingrepo",
|
||||||
"UserFilesRoot": "files",
|
"Blog": "blog",
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ public sealed class ServerServicesFixture : IDisposable
|
||||||
// Details itself doesn't read it, but the service constructor
|
// Details itself doesn't read it, but the service constructor
|
||||||
// throws if SiteSettings.Blog is null, so configure a temp
|
// throws if SiteSettings.Blog is null, so configure a temp
|
||||||
// value to keep construction alive.
|
// value to keep construction alive.
|
||||||
services.Configure<SiteSettings>(s => s.UserFilesRoot = "server-tests-files-root");
|
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
|
||||||
|
|
||||||
// Trivial file-system auth: Details never calls into it, but
|
// Trivial file-system auth: Details never calls into it, but
|
||||||
// the DI container needs an instance to construct the service.
|
// the DI container needs an instance to construct the service.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue