Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b90f15333d |
|||
|
dc4412b9e8 |
|||
|
c1254a522f |
|||
|
40eb5086c1 |
|||
|
d24244aae6 |
|||
|
2744619249 |
|||
|
da35ac0a83 |
|||
|
875a020e0d |
|||
|
eb347e6900 |
|||
|
c34941a4b9 |
|||
|
afbefae111 |
|||
|
29a5b8be8d |
|||
|
d7e274c93a |
|||
|
bc8ae74fc3 |
|||
|
b5fa7b9072 |
|||
|
4ec169ed71 |
|||
|
63b44b5a92 |
|||
|
96ff4af440 |
|||
|
79ba934bce |
|||
|
2765d3eef8 |
|||
|
446b595077 |
|||
|
f9d95fd98f |
|||
|
fae754f102 |
|||
|
8b4b6a7c53 |
|||
|
7ee0c30500 |
|||
|
ebdf51f468 |
|||
|
cd7351a783 |
|||
|
95e8f251d2 |
|||
|
e7721bc3ab |
|||
|
3f73d7a909 |
81 changed files with 2786 additions and 346 deletions
3
.github/copilot-instructions.md
vendored
Normal file
3
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Conventions du projet
|
||||||
|
|
||||||
|
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.
|
||||||
21
.github/workflows/docker-publish-backend.yml
vendored
21
.github/workflows/docker-publish-backend.yml
vendored
|
|
@ -8,8 +8,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
production-deploy:
|
production-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-26.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# 1. Récupération du code
|
# 1. Récupération du code
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
|
|
@ -25,8 +24,24 @@ 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
|
||||||
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
|
env:
|
||||||
|
TEST_FAILED: ""
|
||||||
|
run: make test || export TEST_FAILED=$?
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: test-reports/**/*
|
||||||
|
- name: Fail if tests failed
|
||||||
|
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
|
||||||
|
run: exit $TEST_FAILED
|
||||||
|
|
||||||
# 4. Build et Push de l'image de production finale
|
# 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
|
||||||
|
|
|
||||||
90
CHANGELOG.md
90
CHANGELOG.md
|
|
@ -1,10 +1,100 @@
|
||||||
# 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
|
||||||
|
l'espace disponible et défilent séparément ; un séparateur permet
|
||||||
|
de redimensionner les deux zones.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* [PostIt] Défilement des articles longs en édition et en aperçu :
|
||||||
|
suppression de la limite de hauteur qui masquait le contenu Markdown
|
||||||
|
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
|
||||||
|
couvrent les barres automatiques, le redimensionnement et le
|
||||||
|
défilement indépendant de la liste.
|
||||||
|
|
||||||
## [1.0.8-rc23] - unstable
|
## [1.0.8-rc23] - unstable
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
|
||||||
|
liste, mis à jour lors de la publication ou du retour en brouillon.
|
||||||
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
|
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
|
||||||
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
|
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
|
||||||
d'avatar, confirmation e-mail, préférence de courriel mensuel,
|
d'avatar, confirmation e-mail, préférence de courriel mensuel,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,12 @@
|
||||||
# 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:debian12-dotnet10-android36-v1 AS build-env
|
FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 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,8 +5,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
|
||||||
* ~/Views/Home/Index.cshtml
|
* ~/Views/Home/Index.cshtml
|
||||||
* ~/Views/Home/About.cshtml
|
* ~/Views/Home/About.cshtml
|
||||||
|
|
||||||
# 👥 Crédits & Co-autoraClaudt
|
# 👥 Crédits & Co-auteurs
|
||||||
|
|
||||||
Ce projet a été développé de manière hybride :
|
Ce projet a été développé de manière hybride :
|
||||||
- **Conception, architecture et revue de code :** Paul Schneider
|
- **Conception, architecture et revue de code :** Paul Schneider
|
||||||
|
|
|
||||||
BIN
contrib/blog-edit.png
Normal file
BIN
contrib/blog-edit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
contrib/blog-view.png
Normal file
BIN
contrib/blog-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -8,7 +8,7 @@
|
||||||
"CorsAllowedOrigins": [
|
"CorsAllowedOrigins": [
|
||||||
"https://preprod-api.$DOMAIN"
|
"https://preprod-api.$DOMAIN"
|
||||||
],
|
],
|
||||||
"Blog": "blog"
|
"UserFilesRoot": "files"
|
||||||
},
|
},
|
||||||
"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,
|
||||||
"Blog": "blog",
|
"UserFilesRoot": "files",
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"TempDir": "temp",
|
"TempDir": "temp",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,51 @@ 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
|
||||||
|
|
@ -126,6 +171,20 @@ le DI est construit. Ordre, dans cet ordre :
|
||||||
> à cette table, *ne pas* ajouter de registration pour
|
> à cette table, *ne pas* ajouter de registration pour
|
||||||
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
|
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
|
||||||
|
|
||||||
|
## Défilement de la page des billets
|
||||||
|
|
||||||
|
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
|
||||||
|
et les commandes restent visibles, tandis que la liste des billets et
|
||||||
|
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
|
||||||
|
de modifier cette répartition. La liste et l'article défilent séparément.
|
||||||
|
|
||||||
|
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
|
||||||
|
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
|
||||||
|
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
|
||||||
|
dépasser le viewport pour que les barres automatiques s'activent.
|
||||||
|
Ne pas placer cette grille dans un `StackPanel` vertical ou un
|
||||||
|
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
|
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<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,6 +73,14 @@ 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,8 +20,13 @@ namespace PostIt.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BlogAttachmentLinkTests
|
public class BlogAttachmentLinkTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
|
[InlineData("note.txt", "note.txt")]
|
||||||
|
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
||||||
|
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
||||||
|
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
|
||||||
|
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
|
||||||
|
string originalName, string urlName)
|
||||||
{
|
{
|
||||||
const string authority = "https://idp.example.test";
|
const string authority = "https://idp.example.test";
|
||||||
|
|
||||||
|
|
@ -53,27 +58,59 @@ 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(
|
||||||
"note.txt",
|
originalName,
|
||||||
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();
|
||||||
|
|
||||||
// The update branch issues two PUTs: the multipart one
|
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
|
||||||
// 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 expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
|
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
|
||||||
var sentStringContent = multipartContent.First() as StringContent ;
|
|
||||||
|
|
||||||
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
|
using var document = System.Text.Json.JsonDocument.Parse(sentString);
|
||||||
|
var article = document.RootElement.GetProperty("article").GetString();
|
||||||
|
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
|
||||||
// The regression shape: a relative, authority-less link.
|
// The regression shape: a relative, authority-less link.
|
||||||
Assert.DoesNotContain("](/files/", sentString);
|
Assert.DoesNotContain("](/files/", article);
|
||||||
|
Assert.Equal(draftBeforeSave, vm.DraftArticle);
|
||||||
|
Assert.NotNull(vm.SelectedPost);
|
||||||
|
Assert.False(vm.IsDraftModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task New_post_resolves_only_the_provisional_link_at_first_save()
|
||||||
|
{
|
||||||
|
var recorder = new CallRecorder();
|
||||||
|
var api = new RecordingYavscApiClient(recorder);
|
||||||
|
var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"),
|
||||||
|
new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } });
|
||||||
|
vm.DraftTitle = "Nouveau billet";
|
||||||
|
vm.DraftArticle = "Texte conservé.";
|
||||||
|
vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 }));
|
||||||
|
|
||||||
|
Assert.Contains("postit-attachment:", vm.DraftArticle);
|
||||||
|
Assert.Empty(recorder.Calls);
|
||||||
|
await vm.SaveAsync();
|
||||||
|
|
||||||
|
Assert.Equal("Nouveau billet", vm.DraftTitle);
|
||||||
|
Assert.Contains("Texte conservé.", vm.DraftArticle);
|
||||||
|
Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)",
|
||||||
|
vm.DraftArticle);
|
||||||
|
Assert.DoesNotContain("postit-attachment:", vm.DraftArticle);
|
||||||
|
Assert.Equal(42, vm.SelectedPost?.Id);
|
||||||
|
Assert.Empty(vm.DraftAttachments);
|
||||||
|
Assert.False(vm.IsDraftModified);
|
||||||
|
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post);
|
||||||
|
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
346
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
346
src/PostIt/PostIt.Tests/BlogReaderTests.cs
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
using System.Net;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.Logging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using Markdown.Avalonia;
|
||||||
|
using ColorTextBlock.Avalonia;
|
||||||
|
using PostIt.Models;
|
||||||
|
using PostIt.Services;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views;
|
||||||
|
using PostIt.Views.Blogs;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class BlogReaderTests
|
||||||
|
{
|
||||||
|
private static BlogPostDto Post(string article = "Texte du billet.") => new()
|
||||||
|
{
|
||||||
|
Id = 42,
|
||||||
|
Title = "Un billet à lire",
|
||||||
|
Article = article,
|
||||||
|
AuthorId = "user-id-not-login",
|
||||||
|
Author = new BlogPostAuthorDto { UserName = "paul" },
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null)
|
||||||
|
=> new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"),
|
||||||
|
new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } },
|
||||||
|
resources: resources);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
vm.SelectedPost = Post();
|
||||||
|
Assert.False(vm.IsEditing);
|
||||||
|
Assert.Equal("paul", vm.ReaderAuthor);
|
||||||
|
Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl);
|
||||||
|
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reading_does_not_publish_or_discard_unsaved_editor_changes()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
vm.SelectedPost = Post();
|
||||||
|
vm.EditPostCommand.Execute(null);
|
||||||
|
vm.DraftArticle = "Modifications non enregistrées";
|
||||||
|
vm.ReadPostCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsEditing);
|
||||||
|
Assert.Equal("Texte du billet.", vm.ReaderArticle);
|
||||||
|
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||||
|
Assert.True(vm.IsDraftModified);
|
||||||
|
vm.EditPostCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsEditing);
|
||||||
|
Assert.Equal("Modifications non enregistrées", vm.DraftArticle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void New_post_is_an_explicit_action_and_protects_unsaved_changes()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
vm.SelectedPost = Post();
|
||||||
|
vm.NewPostCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsEditing);
|
||||||
|
Assert.Null(vm.SelectedPost);
|
||||||
|
Assert.Empty(vm.DraftTitle);
|
||||||
|
Assert.Empty(vm.DraftArticle!);
|
||||||
|
vm.DraftTitle = "Nouveau brouillon";
|
||||||
|
vm.NewPostCommand.Execute(null);
|
||||||
|
Assert.Equal("Nouveau brouillon", vm.DraftTitle);
|
||||||
|
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
vm.SelectedPost = Post("[Vidéo](clip.mp4)");
|
||||||
|
var page = new BlogsPage { DataContext = vm };
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
await nav.PushAsync(page);
|
||||||
|
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
Assert.True(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||||
|
Assert.False(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||||
|
Assert.False(page.SaveButton.IsVisible);
|
||||||
|
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||||
|
Assert.Equal(vm.ReaderArticle, reader.Markdown);
|
||||||
|
Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot);
|
||||||
|
Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine).HyperlinkCommand);
|
||||||
|
vm.EditPostCommand.Execute(null);
|
||||||
|
Assert.False(page.FindControl<Grid>("PostReader")!.IsVisible);
|
||||||
|
Assert.True(page.FindControl<Grid>("PostEditor")!.IsVisible);
|
||||||
|
Assert.True(page.SaveButton.IsVisible);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
var post = Post();
|
||||||
|
vm.Posts.Add(post);
|
||||||
|
vm.FilteredPosts.Add(post);
|
||||||
|
var previousSink = Logger.Sink;
|
||||||
|
var sink = new RecordingLogSink();
|
||||||
|
Logger.Sink = sink;
|
||||||
|
Window? window = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = new BlogsPage();
|
||||||
|
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||||
|
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
||||||
|
Assert.NotNull(engine.HyperlinkCommand);
|
||||||
|
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
|
||||||
|
page.DataContext = vm;
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
await nav.PushAsync(page);
|
||||||
|
window = new Window { Width = 800, Height = 700, Content = nav };
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.Empty(vm.ReaderTitle);
|
||||||
|
Assert.Null(vm.ReaderDateModified);
|
||||||
|
Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand);
|
||||||
|
Assert.IsType<BlogImageResolver>(reader.Plugins.PathResolver);
|
||||||
|
|
||||||
|
vm.SelectedPost = post;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.Equal(post.Title, vm.ReaderTitle);
|
||||||
|
Assert.Equal(post.DateModified, vm.ReaderDateModified);
|
||||||
|
|
||||||
|
vm.SelectedPost = null;
|
||||||
|
page.DataContext = null;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.Empty(vm.ReaderTitle);
|
||||||
|
Assert.Null(vm.ReaderDateModified);
|
||||||
|
Assert.NotNull(engine.HyperlinkCommand);
|
||||||
|
Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/"));
|
||||||
|
Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window?.Close();
|
||||||
|
Logger.Sink = previousSink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
|
||||||
|
{
|
||||||
|
const string url = "https://org.example.test/files/paul/absent.jpg";
|
||||||
|
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(
|
||||||
|
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||||
|
var resolver = new BlogImageResolver(client);
|
||||||
|
var previousSink = Logger.Sink;
|
||||||
|
var sink = new RecordingLogSink();
|
||||||
|
Logger.Sink = sink;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<HttpRequestException>(() => resolver.ResolveImageResource(url));
|
||||||
|
var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages");
|
||||||
|
Assert.Contains(url, error.Values);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Logger.Sink = previousSink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingLogSink : ILogSink
|
||||||
|
{
|
||||||
|
public List<(string Area, object?[] Values)> Entries { get; } = [];
|
||||||
|
public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning;
|
||||||
|
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
|
||||||
|
=> Entries.Add((area, []));
|
||||||
|
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values)
|
||||||
|
=> Entries.Add((area, values));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
|
||||||
|
{
|
||||||
|
var vm = CreateViewModel();
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}.")));
|
||||||
|
var page = new BlogsPage { DataContext = vm };
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
await nav.PushAsync(page);
|
||||||
|
var window = new Window { Width = 800, Height = 700, Content = nav };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||||
|
var scroll = reader.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||||
|
Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height);
|
||||||
|
Assert.True(scroll.Extent.Height > scroll.Viewport.Height);
|
||||||
|
scroll.ScrollToEnd();
|
||||||
|
Assert.True(scroll.Offset.Y > 0);
|
||||||
|
vm.SelectedPost = null;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.Empty(reader.Markdown!);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
|
||||||
|
[InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
|
||||||
|
[InlineData("blogs/14/IMG-20260923-WA0001.jpg")]
|
||||||
|
public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url)
|
||||||
|
{
|
||||||
|
using var source = new SKBitmap(32, 24);
|
||||||
|
source.Erase(SKColors.CornflowerBlue);
|
||||||
|
using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90);
|
||||||
|
var jpeg = encoded.ToArray();
|
||||||
|
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
||||||
|
{
|
||||||
|
Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg",
|
||||||
|
request.RequestUri!.AbsoluteUri);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StreamContent(new NonSeekableStream(jpeg))
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
var resolver = new BlogImageResolver(client)
|
||||||
|
{
|
||||||
|
AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/"
|
||||||
|
};
|
||||||
|
await using var stream = await resolver.ResolveImageResource(url);
|
||||||
|
Assert.NotNull(stream);
|
||||||
|
Assert.True(stream.CanSeek);
|
||||||
|
Assert.Equal(0, stream.Position);
|
||||||
|
using var decoded = SKBitmap.Decode(stream);
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(32, decoded.Width);
|
||||||
|
Assert.Equal(24, decoded.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NonSeekableStream(byte[] bytes) : Stream
|
||||||
|
{
|
||||||
|
private readonly MemoryStream _inner = new(bytes, writable: false);
|
||||||
|
public override bool CanRead => _inner.CanRead;
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
public override bool CanWrite => false;
|
||||||
|
public override long Length => throw new NotSupportedException();
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => throw new NotSupportedException();
|
||||||
|
set => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
||||||
|
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||||
|
=> _inner.ReadAsync(buffer, cancellationToken);
|
||||||
|
public override void Flush() => throw new NotSupportedException();
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
_inner.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
|
||||||
|
{
|
||||||
|
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
|
||||||
|
{
|
||||||
|
Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri);
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
response.Content.Headers.ContentType = new("video/mp4");
|
||||||
|
return response;
|
||||||
|
}));
|
||||||
|
var vm = CreateViewModel(new BlogResourceService(client));
|
||||||
|
vm.SelectedPost = Post();
|
||||||
|
var app = (App)Application.Current!;
|
||||||
|
var root = new MainView();
|
||||||
|
app.AttachMainWindow(root);
|
||||||
|
var template = new FuncDataTemplate<VideoPageViewModel>((_, _) => new ContentPage());
|
||||||
|
app.DataTemplates.Insert(0, template);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = new BlogsPage { DataContext = vm };
|
||||||
|
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
|
||||||
|
var link = Assert.IsType<CHyperlink>(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)")));
|
||||||
|
Assert.NotNull(link.Command);
|
||||||
|
link.Command(link.CommandParameter!);
|
||||||
|
Assert.NotNull(vm.OpenResourceCommand.ExecutionTask);
|
||||||
|
await vm.OpenResourceCommand.ExecutionTask;
|
||||||
|
var target = Assert.IsType<VideoPageViewModel>(root.NavRoot.NavigationStack.Last().DataContext);
|
||||||
|
Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri);
|
||||||
|
Assert.Equal("video/mp4", target.MimeType);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
app.DataTemplates.Remove(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
|
||||||
|
{
|
||||||
|
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
|
||||||
|
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
|
||||||
|
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Broken_resource_reports_an_error_and_releases_busy_state()
|
||||||
|
{
|
||||||
|
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound)));
|
||||||
|
var vm = CreateViewModel(new BlogResourceService(client));
|
||||||
|
vm.SelectedPost = Post();
|
||||||
|
await vm.OpenResourceCommand.ExecuteAsync("absent.mp4");
|
||||||
|
Assert.False(vm.IsBusy);
|
||||||
|
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
137
src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using PostIt.Services;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class BlogResourceServiceTests
|
||||||
|
{
|
||||||
|
private const string Authority = "https://yavsc.pschneider.fr";
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")]
|
||||||
|
[InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")]
|
||||||
|
[InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")]
|
||||||
|
[InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||||
|
[InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")]
|
||||||
|
[InlineData("//elsewhere.test/page", "https://elsewhere.test/page")]
|
||||||
|
public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected)
|
||||||
|
=> Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encodes_login_and_keeps_trailing_slash()
|
||||||
|
=> Assert.Equal(Authority + "/files/paul%20test/",
|
||||||
|
BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Authority + "/files/paul/movie.mp4", true)]
|
||||||
|
[InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)]
|
||||||
|
[InlineData(Authority + "/files/paul/", false)]
|
||||||
|
[InlineData(Authority + "/files/paul", false)]
|
||||||
|
[InlineData(Authority + "/files/", false)]
|
||||||
|
[InlineData(Authority + "/files-other/paul/movie.mp4", false)]
|
||||||
|
[InlineData(Authority + "/page.mp4", false)]
|
||||||
|
[InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)]
|
||||||
|
[InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)]
|
||||||
|
[InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)]
|
||||||
|
[InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||||
|
[InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)]
|
||||||
|
public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected)
|
||||||
|
=> Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("javascript:alert(1)")]
|
||||||
|
[InlineData("file:///tmp/video.mp4")]
|
||||||
|
[InlineData("data:video/mp4;base64,AAAA")]
|
||||||
|
public void Rejects_non_web_links(string link)
|
||||||
|
=> Assert.Throws<InvalidOperationException>(() => BlogResourceService.Resolve(link, Authority, "paul"));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("video/webm", "movie.jpg", "video/webm")]
|
||||||
|
[InlineData("image/jpeg", "movie.mp4", "image/jpeg")]
|
||||||
|
[InlineData("application/octet-stream", "movie.MP4", "video/mp4")]
|
||||||
|
[InlineData(null, "movie.webm", "video/webm")]
|
||||||
|
[InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")]
|
||||||
|
[InlineData(null, "file.unknown", "application/octet-stream")]
|
||||||
|
public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient(new StubHandler(request =>
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpMethod.Head, request.Method);
|
||||||
|
return Response(HttpStatusCode.OK, mime);
|
||||||
|
}));
|
||||||
|
var service = new BlogResourceService(client);
|
||||||
|
Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"),
|
||||||
|
TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.MethodNotAllowed)]
|
||||||
|
[InlineData(HttpStatusCode.NotImplemented)]
|
||||||
|
public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status)
|
||||||
|
{
|
||||||
|
var calls = 0;
|
||||||
|
using var client = new HttpClient(new StubHandler(request =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
if (request.Method == HttpMethod.Head)
|
||||||
|
return Response(status, null);
|
||||||
|
Assert.Equal(HttpMethod.Get, request.Method);
|
||||||
|
Assert.Equal("bytes=0-0", request.Headers.Range?.ToString());
|
||||||
|
return Response(HttpStatusCode.PartialContent, "video/mp4");
|
||||||
|
}));
|
||||||
|
Assert.Equal("video/mp4", await new BlogResourceService(client)
|
||||||
|
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken));
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.NotFound)]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized)]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError)]
|
||||||
|
public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient(new StubHandler(_ => Response(status, null)));
|
||||||
|
await Assert.ThrowsAsync<HttpRequestException>(() => new BlogResourceService(client)
|
||||||
|
.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")]
|
||||||
|
[InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||||
|
[InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")]
|
||||||
|
public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient(new StubHandler(request =>
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, request.RequestUri!.AbsoluteUri);
|
||||||
|
return Response(HttpStatusCode.OK, "image/jpeg");
|
||||||
|
}));
|
||||||
|
var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" };
|
||||||
|
await using var stream = await resolver.ResolveImageResource(link);
|
||||||
|
Assert.NotNull(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("file:///etc/passwd")]
|
||||||
|
[InlineData("data:image/png;base64,AAAA")]
|
||||||
|
public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link)
|
||||||
|
{
|
||||||
|
var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" };
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => resolver.ResolveImageResource(link));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Response(HttpStatusCode status, string? mime)
|
||||||
|
{
|
||||||
|
var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) };
|
||||||
|
if (mime is not null)
|
||||||
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(respond(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
199
src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using System.Net.Http;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using PostIt.Services;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class HomePageNotificationsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_loads_public_notifications_from_the_business_host()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
|
||||||
|
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
|
||||||
|
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
|
||||||
|
Assert.True(transport.WasAnonymous);
|
||||||
|
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
|
||||||
|
Assert.False(vm.IsLoadingNotifications);
|
||||||
|
Assert.False(vm.HasNoNotifications);
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
vm.Notifications.Add(Note(1, "Old"));
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
|
||||||
|
Assert.Empty(vm.Notifications);
|
||||||
|
Assert.True(vm.HasNoNotifications);
|
||||||
|
|
||||||
|
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
|
||||||
|
Assert.Empty(vm.Notifications);
|
||||||
|
Assert.False(vm.HasNoNotifications);
|
||||||
|
Assert.Contains("Server unavailable", vm.NotificationsError);
|
||||||
|
Assert.False(vm.IsLoadingNotifications);
|
||||||
|
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
Assert.Null(vm.NotificationsError);
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
var oldResponse = new TaskCompletionSource<List<Notification>>();
|
||||||
|
transport.Response = oldResponse.Task;
|
||||||
|
var oldLoad = vm.RefreshNotificationsAsync();
|
||||||
|
Assert.True(vm.IsLoadingNotifications);
|
||||||
|
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
|
||||||
|
await vm.RefreshNotificationsAsync();
|
||||||
|
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
|
||||||
|
await oldLoad;
|
||||||
|
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
Assert.False(vm.IsLoadingNotifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
|
||||||
|
{
|
||||||
|
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
|
||||||
|
var settings = new Settings();
|
||||||
|
var store = new TokenStore(tokenPath);
|
||||||
|
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
|
||||||
|
DateTimeOffset.UtcNow.AddHours(1), null));
|
||||||
|
await using var api = new YavscApiClient(settings, store);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = new SessionStatusViewModel { Api = api };
|
||||||
|
var transport = new NotificationTransport();
|
||||||
|
var vm = CreateViewModel(transport, session);
|
||||||
|
session.Refresh();
|
||||||
|
Assert.False(transport.WasAnonymous);
|
||||||
|
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||||
|
var privateResponse = new TaskCompletionSource<List<Notification>>();
|
||||||
|
transport.Response = privateResponse.Task;
|
||||||
|
var privateLoad = vm.RefreshNotificationsAsync();
|
||||||
|
Assert.False(transport.WasAnonymous);
|
||||||
|
Assert.EndsWith("/notifications/me", transport.LastPath);
|
||||||
|
|
||||||
|
vm.Notifications.Add(Note(9, "Private"));
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
|
||||||
|
session.SetError("Disconnected");
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
Assert.True(transport.WasAnonymous);
|
||||||
|
|
||||||
|
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
|
||||||
|
await privateLoad;
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(tokenPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
|
||||||
|
{
|
||||||
|
var transport = new NotificationTransport
|
||||||
|
{
|
||||||
|
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
|
||||||
|
};
|
||||||
|
var vm = CreateViewModel(transport);
|
||||||
|
var page = new HomePage { DataContext = vm };
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
var window = new Window { Content = nav, Width = 320, Height = 450 };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
await nav.PushAsync(page);
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.Single(vm.Notifications);
|
||||||
|
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
|
||||||
|
Assert.Contains(texts, t => t.Text == "Notification title"
|
||||||
|
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
|
||||||
|
Assert.Contains(texts, t => t.Text == "Notification body"
|
||||||
|
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
|
||||||
|
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
|
||||||
|
b => b.Command == vm.RefreshNotificationsCommand);
|
||||||
|
Assert.True(refresh.IsEnabled);
|
||||||
|
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
|
||||||
|
s => s.Extent.Height > s.Viewport.Height);
|
||||||
|
|
||||||
|
await nav.PushAsync(new ContentPage());
|
||||||
|
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
|
||||||
|
await nav.PopAsync();
|
||||||
|
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
|
||||||
|
SessionStatusViewModel? session = null)
|
||||||
|
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
|
||||||
|
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
|
||||||
|
|
||||||
|
private static Notification Note(long id, string title)
|
||||||
|
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
|
||||||
|
|
||||||
|
private sealed class NotificationTransport : IYavscApiClient
|
||||||
|
{
|
||||||
|
public HttpClient Http { get; } = new();
|
||||||
|
public string? LastPath { get; private set; }
|
||||||
|
public bool WasAnonymous { get; private set; }
|
||||||
|
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
|
||||||
|
|
||||||
|
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
|
||||||
|
=> ReadAsync<T>(path, true);
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpMethod.Get, method);
|
||||||
|
return ReadAsync<T>(path, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ReadAsync<T>(string path, bool anonymous)
|
||||||
|
{
|
||||||
|
LastPath = path;
|
||||||
|
WasAnonymous = anonymous;
|
||||||
|
return (T)(object)await Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task CallAsync(HttpMethod method, string path, object? body = null,
|
||||||
|
CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
|
||||||
|
CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Http.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ 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
|
||||||
|
|
|
||||||
143
src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs
Normal file
143
src/PostIt/PostIt.Tests/MarkdownEditorScrollTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.LogicalTree;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using AvaloniaEdit;
|
||||||
|
using PostIt.Controls;
|
||||||
|
using PostIt.Views.Blogs;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class MarkdownEditorScrollTests
|
||||||
|
{
|
||||||
|
private static readonly string LongArticle =
|
||||||
|
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
|
||||||
|
|
||||||
|
[AvaloniaTheory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
|
||||||
|
{
|
||||||
|
var editor = new MarkdownEditorControl
|
||||||
|
{
|
||||||
|
MarkdownText = LongArticle,
|
||||||
|
IsPreviewVisible = preview
|
||||||
|
};
|
||||||
|
var window = new Window { Width = 600, Height = 400, Content = editor };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
var scroller = FindArticleScroller(editor, preview);
|
||||||
|
AssertScrollable(scroller);
|
||||||
|
var initialViewport = scroller.Viewport.Height;
|
||||||
|
|
||||||
|
window.Height = 300;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
Assert.True(scroller.Viewport.Height < initialViewport);
|
||||||
|
AssertScrollable(scroller);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseWindow(window, editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaTheory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Blog_page_bounds_the_article_viewport(bool preview)
|
||||||
|
{
|
||||||
|
var page = new BlogsPage { DataContext = new PostIt.ViewModels.BlogsViewModel { IsEditing = true } };
|
||||||
|
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
|
||||||
|
editor.MarkdownText = LongArticle;
|
||||||
|
editor.IsPreviewVisible = preview;
|
||||||
|
var posts = page.FindControl<ListBox>("PostsListBox")!;
|
||||||
|
posts.ItemsSource = Enumerable.Range(1, 50)
|
||||||
|
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
|
||||||
|
var navigation = new NavigationPage();
|
||||||
|
navigation.PushAsync(page).GetAwaiter().GetResult();
|
||||||
|
var window = new Window { Width = 800, Height = 700, Content = navigation };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
var scroller = FindArticleScroller(editor, preview);
|
||||||
|
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
|
||||||
|
AssertScrollable(scroller);
|
||||||
|
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
|
||||||
|
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
|
||||||
|
Assert.NotNull(editorBottom);
|
||||||
|
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseWindow(window, editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaTheory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Short_article_needs_no_vertical_scrollbar(bool preview)
|
||||||
|
{
|
||||||
|
var editor = new MarkdownEditorControl
|
||||||
|
{
|
||||||
|
MarkdownText = "Short article",
|
||||||
|
IsPreviewVisible = preview
|
||||||
|
};
|
||||||
|
var window = new Window { Width = 600, Height = 400, Content = editor };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
|
||||||
|
var scroller = FindArticleScroller(editor, preview);
|
||||||
|
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
|
||||||
|
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
|
||||||
|
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseWindow(window, editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CloseWindow(Window window, MarkdownEditorControl editor)
|
||||||
|
{
|
||||||
|
// The package's shared renderer caches the visual; detach it between headless applications.
|
||||||
|
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
|
||||||
|
window.UpdateLayout();
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
|
||||||
|
{
|
||||||
|
if (!preview)
|
||||||
|
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
|
||||||
|
.GetVisualDescendants().OfType<ScrollViewer>().Single();
|
||||||
|
|
||||||
|
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertScrollable(ScrollViewer scroller)
|
||||||
|
{
|
||||||
|
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
|
||||||
|
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
|
||||||
|
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
|
||||||
|
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
|
||||||
|
scroller.ScrollToEnd();
|
||||||
|
Assert.True(scroller.Offset.Y > 0);
|
||||||
|
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,132 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
using Yavsc.Blogspot;
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views.Blogs;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
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]
|
||||||
|
public void Published_badge_tracks_post_publication_state()
|
||||||
|
{
|
||||||
|
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
|
||||||
|
var page = new BlogsPage();
|
||||||
|
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
|
||||||
|
row.DataContext = post;
|
||||||
|
var window = new Window { Content = row };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
window.Show();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
|
||||||
|
border => border.Name == "PublishedBadge");
|
||||||
|
|
||||||
|
Assert.True(badge.IsVisible);
|
||||||
|
|
||||||
|
post.IsPublished = false;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.False(badge.IsVisible);
|
||||||
|
|
||||||
|
post.IsPublished = true;
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.True(badge.IsVisible);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SearchCommand_filters_posts_by_title_article_or_author()
|
public void SearchCommand_filters_posts_by_title_article_or_author()
|
||||||
|
|
@ -61,11 +181,22 @@ public class PostItViewModelTests
|
||||||
var viewModel = new BlogsViewModel(blog);
|
var viewModel = new BlogsViewModel(blog);
|
||||||
|
|
||||||
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
|
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
|
||||||
|
var changes = new List<string?>();
|
||||||
|
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
|
||||||
|
|
||||||
await viewModel.SetPublishStateAsync(true);
|
await viewModel.SetPublishStateAsync(true);
|
||||||
|
|
||||||
Assert.True(api.LastPublishValue);
|
Assert.True(api.LastPublishValue);
|
||||||
Assert.True(viewModel.DraftIsPublished);
|
Assert.True(viewModel.DraftIsPublished);
|
||||||
|
Assert.True(viewModel.SelectedPost.IsPublished);
|
||||||
|
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
|
||||||
|
|
||||||
|
await viewModel.SetPublishStateAsync(false);
|
||||||
|
|
||||||
|
Assert.False(api.LastPublishValue);
|
||||||
|
Assert.False(viewModel.DraftIsPublished);
|
||||||
|
Assert.False(viewModel.SelectedPost.IsPublished);
|
||||||
|
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Test fake that always throws if the API is invoked.</summary>
|
/// <summary>Test fake that always throws if the API is invoked.</summary>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -23,41 +22,36 @@ 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>The session banner's <c>DataContext</c> is not wired in
|
/// <para>These tests set the DataContext directly on the banner
|
||||||
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
|
/// mounted in the window. The production code path is exercised
|
||||||
/// 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
|
public class SessionStatusBannerTests : IDisposable
|
||||||
{
|
{
|
||||||
[AvaloniaFact]
|
private MainWindow? _window;
|
||||||
public void Banner_renders_three_buttons_in_the_visual_tree()
|
|
||||||
{
|
|
||||||
MainWindow window = new MainWindow();
|
|
||||||
window.Show();
|
|
||||||
|
|
||||||
var buttons = window.GetVisualDescendants()
|
[AvaloniaFact]
|
||||||
|
public void Banner_renders_four_buttons_in_the_visual_tree()
|
||||||
|
{
|
||||||
|
SessionStatusBanner banner = CreateBanner();
|
||||||
|
|
||||||
|
var buttons = banner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Three buttons, named by their content text: Se
|
Assert.Equal(4, buttons.Count);
|
||||||
// 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()
|
||||||
{
|
{
|
||||||
MainWindow window = new MainWindow();
|
SessionStatusBanner banner = CreateBanner();
|
||||||
window.Show();
|
|
||||||
|
|
||||||
var login = window.GetVisualDescendants()
|
var login = banner.GetVisualDescendants()
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Se connecter");
|
.Single(b => b.Content as string == "Se connecter");
|
||||||
|
|
||||||
|
|
@ -71,10 +65,7 @@ public class SessionStatusBannerTests
|
||||||
{
|
{
|
||||||
SessionStatusBanner banner = CreateBanner();
|
SessionStatusBanner banner = CreateBanner();
|
||||||
|
|
||||||
Assert.False((banner.DataContext as SessionStatusViewModel)!
|
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
|
||||||
.IsLoggedIn); // VM default
|
|
||||||
|
|
||||||
|
|
||||||
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");
|
||||||
|
|
@ -91,22 +82,23 @@ public class SessionStatusBannerTests
|
||||||
.OfType<Button>()
|
.OfType<Button>()
|
||||||
.Single(b => b.Content as string == "Paramètres");
|
.Single(b => b.Content as string == "Paramètres");
|
||||||
|
|
||||||
// Paramètres is the only button with no IsVisible
|
// Paramètres has no IsVisible binding and is always shown.
|
||||||
// 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 static SessionStatusBanner CreateBanner()
|
private SessionStatusBanner CreateBanner()
|
||||||
{
|
{
|
||||||
MainWindow window = new MainWindow();
|
_window = new MainWindow();
|
||||||
window.Show();
|
_window.Show();
|
||||||
var banner = new SessionStatusBanner();
|
var banner = _window.GetVisualDescendants()
|
||||||
var status = new SessionStatusViewModel();
|
.OfType<SessionStatusBanner>()
|
||||||
banner.DataContext = status;
|
.Single();
|
||||||
|
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,6 +17,56 @@ 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,10 +44,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,13 @@
|
||||||
x:Name="Root"
|
x:Name="Root"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch">
|
VerticalAlignment="Stretch">
|
||||||
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="260">
|
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||||
<Border
|
<Border
|
||||||
BorderBrush="LightGray"
|
BorderBrush="LightGray"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
CornerRadius="5"
|
CornerRadius="5"
|
||||||
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
|
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
|
||||||
Padding="0"
|
Padding="0">
|
||||||
MinHeight="260">
|
|
||||||
<ScrollViewer
|
|
||||||
BringIntoViewOnFocusChange="False"
|
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
|
||||||
VerticalScrollBarVisibility="Auto"
|
|
||||||
HorizontalAlignment="Stretch"
|
|
||||||
VerticalAlignment="Stretch">
|
|
||||||
<AvaloniaEdit:TextEditor
|
<AvaloniaEdit:TextEditor
|
||||||
x:Name="SourceEditor"
|
x:Name="SourceEditor"
|
||||||
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
|
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
|
||||||
|
|
@ -35,7 +28,6 @@
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
TextInputOptions.Multiline="True"
|
TextInputOptions.Multiline="True"
|
||||||
TextInputOptions.ReturnKeyType="Default" />
|
TextInputOptions.ReturnKeyType="Default" />
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border
|
<Border
|
||||||
|
|
@ -43,18 +35,21 @@
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
CornerRadius="5"
|
CornerRadius="5"
|
||||||
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
|
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
|
||||||
Padding="8"
|
Padding="8">
|
||||||
MinHeight="260">
|
|
||||||
<ScrollViewer
|
<ScrollViewer
|
||||||
|
x:Name="PreviewScrollViewer"
|
||||||
BringIntoViewOnFocusChange="False"
|
BringIntoViewOnFocusChange="False"
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch">
|
VerticalAlignment="Stretch"
|
||||||
|
HorizontalScrollBarVisibility="Auto">
|
||||||
<md:MarkdownViewer
|
<md:MarkdownViewer
|
||||||
x:Name="PreviewViewer"
|
x:Name="PreviewViewer"
|
||||||
MarkdownText="{Binding MarkdownText, ElementName=Root}"
|
HorizontalContentAlignment="Stretch"
|
||||||
ClipToBounds="False" />
|
VerticalAlignment="Top"
|
||||||
|
ClipToBounds="False"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ public partial class MarkdownEditorControl : UserControl
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
PropertyChanged += OnPropertyChanged;
|
PropertyChanged += OnPropertyChanged;
|
||||||
SizeChanged += OnSizeChanged;
|
|
||||||
UpdateEditorLimits();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
|
@ -62,27 +60,4 @@ public partial class MarkdownEditorControl : UserControl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
||||||
{
|
|
||||||
UpdateEditorLimits();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateEditorLimits()
|
|
||||||
{
|
|
||||||
var visibleHeight = Bounds.Height > 0 ? Bounds.Height - 24 : 260;
|
|
||||||
visibleHeight = Math.Max(visibleHeight, 80);
|
|
||||||
|
|
||||||
if (SourceEditor is not null)
|
|
||||||
{
|
|
||||||
SourceEditor.MaxHeight = visibleHeight;
|
|
||||||
SourceEditor.Height = double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PreviewViewer is not null)
|
|
||||||
{
|
|
||||||
PreviewViewer.MaxHeight = visibleHeight;
|
|
||||||
PreviewViewer.Height = double.NaN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ 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();
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ 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
|
||||||
|
|
@ -93,6 +95,7 @@ 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,6 +39,7 @@
|
||||||
<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" />
|
||||||
|
|
|
||||||
44
src/PostIt/PostIt/Services/BlogImageResolver.cs
Normal file
44
src/PostIt/PostIt/Services/BlogImageResolver.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Markdown.Avalonia.Utils;
|
||||||
|
using Avalonia.Logging;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
public sealed class BlogImageResolver : IPathResolver
|
||||||
|
{
|
||||||
|
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public BlogImageResolver() : this(Client) { }
|
||||||
|
|
||||||
|
public BlogImageResolver(HttpClient client) => _client = client;
|
||||||
|
|
||||||
|
public string? AssetPathRoot { get; set; }
|
||||||
|
public IEnumerable<string>? CallerAssemblyNames { get; set; }
|
||||||
|
|
||||||
|
public async Task<Stream?> ResolveImageResource(string relativeOrAbsolutePath)
|
||||||
|
{
|
||||||
|
var uri = !relativeOrAbsolutePath.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
&& Uri.TryCreate(relativeOrAbsolutePath, UriKind.Absolute, out var absolute)
|
||||||
|
? absolute
|
||||||
|
: new Uri(new Uri(AssetPathRoot ?? throw new InvalidOperationException(
|
||||||
|
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||||
|
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The Markdown decoder needs a seekable stream positioned at the start.
|
||||||
|
return new MemoryStream(await _client.GetByteArrayAsync(uri), writable: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
Logger.TryGet(LogEventLevel.Error, "BlogImages")?.Log(this,
|
||||||
|
"Unable to load blog image {Url}: {Error}", uri.AbsoluteUri, ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/PostIt/PostIt/Services/BlogResourceService.cs
Normal file
98
src/PostIt/PostIt/Services/BlogResourceService.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Abstract.Files;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
public sealed class BlogResourceService
|
||||||
|
{
|
||||||
|
private static readonly HttpClient SharedClient = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public BlogResourceService() : this(SharedClient) { }
|
||||||
|
|
||||||
|
public BlogResourceService(HttpClient client) => _client = client;
|
||||||
|
|
||||||
|
public static Uri GetUserBaseUri(string authority, string login)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(login))
|
||||||
|
throw new InvalidOperationException("Le login de l'auteur est nécessaire pour résoudre ses fichiers.");
|
||||||
|
return FileServerUrlHelpers.GetUserFilesUri(GetAuthority(authority), Uri.EscapeDataString(login) + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Uri Resolve(string link, string authority, string login)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(link))
|
||||||
|
throw new ArgumentException("Le lien est vide.", nameof(link));
|
||||||
|
var origin = GetAuthority(authority);
|
||||||
|
var uri = link.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
? new Uri(origin, link)
|
||||||
|
: Uri.TryCreate(link, UriKind.Absolute, out var absolute)
|
||||||
|
? absolute
|
||||||
|
: new Uri(GetUserBaseUri(authority, login), link);
|
||||||
|
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||||
|
throw new InvalidOperationException("Seuls les liens HTTP et HTTPS sont pris en charge.");
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsUserResource(Uri uri, string authority)
|
||||||
|
{
|
||||||
|
var root = FileServerUrlHelpers.GetUserFilesBaseUri(GetAuthority(authority));
|
||||||
|
if (!root.IsBaseOf(uri) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||||
|
return false;
|
||||||
|
var path = uri.AbsolutePath[root.AbsolutePath.Length..];
|
||||||
|
var separator = path.IndexOf('/');
|
||||||
|
return separator > 0 && separator < path.Length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetMimeTypeAsync(Uri uri, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||||
|
using var response = await _client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)
|
||||||
|
{
|
||||||
|
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0);
|
||||||
|
using var fallback = await _client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
fallback.EnsureSuccessStatusCode();
|
||||||
|
return GetMimeType(fallback, uri);
|
||||||
|
}
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return GetMimeType(response, uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMimeType(HttpResponseMessage response, Uri uri)
|
||||||
|
{
|
||||||
|
var mime = response.Content.Headers.ContentType?.MediaType;
|
||||||
|
if (!string.IsNullOrEmpty(mime) && !mime.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return mime;
|
||||||
|
return System.IO.Path.GetExtension(uri.AbsolutePath).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".mp4" or ".m4v" => "video/mp4",
|
||||||
|
".webm" => "video/webm",
|
||||||
|
".ogv" => "video/ogg",
|
||||||
|
".ogg" => "audio/ogg",
|
||||||
|
".mov" => "video/quicktime",
|
||||||
|
".mkv" => "video/x-matroska",
|
||||||
|
".avi" => "video/x-msvideo",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => mime ?? "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri GetAuthority(string authority)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||||
|
|| !string.IsNullOrEmpty(uri.UserInfo))
|
||||||
|
throw new InvalidOperationException("L'URL de Yavsc.Org est invalide dans les paramètres d'authentification.");
|
||||||
|
return new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,16 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -614,7 +624,8 @@ 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,6 +42,7 @@ 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,6 +1,7 @@
|
||||||
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;
|
||||||
|
|
@ -10,9 +11,10 @@ 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 PostIt.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using AvaloniaEdit.Document;
|
using AvaloniaEdit.Document;
|
||||||
using PostIt.Models;
|
using Avalonia.Controls;
|
||||||
|
using PostIt.Services;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -90,12 +92,14 @@ 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.
|
||||||
|
|
@ -109,6 +113,14 @@ 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; }
|
||||||
|
|
||||||
|
|
@ -131,8 +143,87 @@ 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; }
|
||||||
|
|
||||||
|
|
@ -158,7 +249,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||||
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
|
||||||
|
|
@ -176,16 +267,6 @@ 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
|
||||||
|
|
@ -196,12 +277,22 @@ 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;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
SelectedPost = created;
|
SelectedPost = created;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_preserveDraft = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (TryAppendAttachmentLinks(created, attachments))
|
if (ResolvePendingAttachmentLinks(created))
|
||||||
{
|
{
|
||||||
var linkUpdate = new BlogPostDto
|
var linkUpdate = new BlogPostDto
|
||||||
{
|
{
|
||||||
|
|
@ -213,16 +304,22 @@ 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,
|
||||||
|
|
@ -234,11 +331,15 @@ 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,6 +532,7 @@ 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.");
|
||||||
|
|
@ -442,6 +544,7 @@ 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
|
||||||
|
|
@ -482,12 +585,14 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -502,6 +607,10 @@ 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
|
||||||
|
|
@ -515,15 +624,59 @@ 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) => SaveCommand.NotifyCanExecuteChanged();
|
partial void OnDraftTitleChanged(string value)
|
||||||
|
{
|
||||||
|
SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
UpdateDraftModified();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDraftModified()
|
||||||
|
{
|
||||||
|
IsDraftModified = !string.Equals(DraftTitle ?? string.Empty, _savedTitle, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(DraftArticle ?? string.Empty, _savedArticle, StringComparison.Ordinal)
|
||||||
|
|| (DraftAttachments?.Count ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkDraftSaved()
|
||||||
|
=> MarkDraftSaved(DraftTitle, DraftArticle);
|
||||||
|
|
||||||
|
private void MarkDraftSaved(string? title, string? article)
|
||||||
|
{
|
||||||
|
_savedTitle = title ?? string.Empty;
|
||||||
|
_savedArticle = article ?? string.Empty;
|
||||||
|
UpdateDraftModified();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task RefreshPostsAsync()
|
private async Task RefreshPostsAsync()
|
||||||
|
|
@ -536,10 +689,6 @@ 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()
|
||||||
|
|
@ -554,10 +703,29 @@ 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);
|
||||||
|
|
||||||
FilteredPosts.Clear();
|
var selection = SelectedPost;
|
||||||
foreach (var post in filtered)
|
var visiblePosts = filtered.Select(post => post.Id == selection?.Id ? selection : post).ToArray();
|
||||||
|
_preserveDraft = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
FilteredPosts.Add(post);
|
for (var i = FilteredPosts.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (!visiblePosts.Contains(FilteredPosts[i]))
|
||||||
|
FilteredPosts.RemoveAt(i);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < visiblePosts.Length; i++)
|
||||||
|
{
|
||||||
|
var previousIndex = FilteredPosts.IndexOf(visiblePosts[i]);
|
||||||
|
if (previousIndex < 0)
|
||||||
|
FilteredPosts.Insert(i, visiblePosts[i]);
|
||||||
|
else if (previousIndex != i)
|
||||||
|
FilteredPosts.Move(previousIndex, i);
|
||||||
|
}
|
||||||
|
SelectedPost = selection;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_preserveDraft = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -596,40 +764,53 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> attachments)
|
private void DraftAttachments_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (attachments.Count == 0)
|
if (e.NewItems is not null)
|
||||||
return false;
|
{
|
||||||
|
foreach (BlogUploadFile attachment in e.NewItems)
|
||||||
var ownerSegment = post.Author?.UserName;
|
{
|
||||||
if (string.IsNullOrWhiteSpace(ownerSegment))
|
var storedName = AbstractFileSystemHelpers.FilterFileName(attachment.FileName);
|
||||||
ownerSegment = post.AuthorId;
|
string fileUrl;
|
||||||
|
if (SelectedPost is { Id: > 0 } post)
|
||||||
if (string.IsNullOrWhiteSpace(ownerSegment))
|
fileUrl = AttachmentUrl(post, storedName);
|
||||||
return false;
|
else
|
||||||
|
{
|
||||||
|
fileUrl = $"postit-attachment:{Guid.NewGuid():N}";
|
||||||
|
_pendingAttachmentLinks.Add(fileUrl, storedName);
|
||||||
|
}
|
||||||
var article = DraftArticle ?? string.Empty;
|
var article = DraftArticle ?? string.Empty;
|
||||||
var links = new List<string>();
|
|
||||||
|
|
||||||
foreach (var attachment in attachments)
|
|
||||||
{
|
|
||||||
var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}";
|
|
||||||
var fileUrl = ResolveUserFileUrl(relativePath);
|
|
||||||
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
|
var markdownLine = $"- [{attachment.FileName}]({fileUrl})";
|
||||||
|
|
||||||
if (!article.Contains(markdownLine, StringComparison.Ordinal))
|
if (!article.Contains(markdownLine, StringComparison.Ordinal))
|
||||||
links.Add(markdownLine);
|
{
|
||||||
|
var prefix = article.Length == 0 ? ""
|
||||||
|
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
||||||
|
DraftArticle = article + prefix + markdownLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateDraftModified();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (links.Count == 0)
|
private string AttachmentUrl(BlogPostDto post, string storedName)
|
||||||
return false;
|
{
|
||||||
|
var owner = post.Author?.UserName ?? post.AuthorId;
|
||||||
|
if (string.IsNullOrWhiteSpace(owner))
|
||||||
|
throw new InvalidOperationException("Le propriétaire du billet est nécessaire au lien du fichier.");
|
||||||
|
return ResolveUserFileUrl(
|
||||||
|
$"{EscapePathSegment(owner)}/blogs/{post.Id}/{EscapePathSegment(storedName)}");
|
||||||
|
}
|
||||||
|
|
||||||
var prefix = article.Length == 0
|
private bool ResolvePendingAttachmentLinks(BlogPostDto post)
|
||||||
? ""
|
{
|
||||||
: (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n");
|
var article = DraftArticle ?? string.Empty;
|
||||||
|
foreach (var link in _pendingAttachmentLinks)
|
||||||
DraftArticle = article + prefix + string.Join("\n", links);
|
article = article.Replace($"({link.Key})", $"({AttachmentUrl(post, link.Value)})",
|
||||||
return true;
|
StringComparison.Ordinal);
|
||||||
|
var changed = article != DraftArticle;
|
||||||
|
if (changed) DraftArticle = article;
|
||||||
|
_pendingAttachmentLinks.Clear();
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveUserFileUrl(string relativePath)
|
private string ResolveUserFileUrl(string relativePath)
|
||||||
|
|
@ -638,7 +819,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).ToString();
|
return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).AbsoluteUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
return $"{Yavsc.Constants.UserFilesPath}/{relativePath}";
|
||||||
|
|
|
||||||
12
src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
Normal file
12
src/PostIt/PostIt/ViewModels/Blogs/VideoPageViewModel.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
public sealed class VideoPageViewModel(Uri source, string mimeType) : ViewModelBase
|
||||||
|
{
|
||||||
|
public Uri Source { get; } = source;
|
||||||
|
public string MimeType { get; } = mimeType;
|
||||||
|
public string Title => Uri.UnescapeDataString(System.IO.Path.GetFileName(Source.AbsolutePath));
|
||||||
|
public override bool CanNavigateNext { get => false; protected set { } }
|
||||||
|
public override bool CanNavigatePrevious { get => true; protected set { } }
|
||||||
|
}
|
||||||
|
|
@ -246,7 +246,7 @@ namespace PostIt.ViewModels.Chat
|
||||||
});
|
});
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
_connection.Reconnected += _ =>
|
_connection.Reconnected += async _ =>
|
||||||
{
|
{
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -254,7 +254,11 @@ namespace PostIt.ViewModels.Chat
|
||||||
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
|
Status = new StatusNotice("Reconnecté au Hub SignalR", StatusSeverity.Info,
|
||||||
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
|
new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
|
||||||
});
|
});
|
||||||
return Task.CompletedTask;
|
// La reconnexion crée une nouvelle ConnectionId côté
|
||||||
|
// serveur : l'appartenance aux salons est perdue, il
|
||||||
|
// faut rejoindre à nouveau le salon courant.
|
||||||
|
if (!string.IsNullOrWhiteSpace(CurrentRoom))
|
||||||
|
await JoinRoomAsync();
|
||||||
};
|
};
|
||||||
_connection.Closed += error =>
|
_connection.Closed += error =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
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
|
||||||
|
|
@ -13,6 +18,26 @@ 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
|
||||||
|
|
@ -24,11 +49,16 @@ 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);
|
||||||
|
|
@ -55,6 +85,59 @@ 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()
|
||||||
{
|
{
|
||||||
|
|
@ -228,7 +311,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())
|
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel(), null!)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
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:md="using:MarkdownViewer.Core.Controls"
|
xmlns:reader="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||||
|
xmlns:services="using:PostIt.Services"
|
||||||
xmlns:models="using:Yavsc.Blogspot"
|
xmlns: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"
|
||||||
|
|
@ -17,14 +16,18 @@
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:BlogsViewModel />
|
<vm:BlogsViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<ScrollViewer
|
<ContentPage.Styles>
|
||||||
|
<Style Selector="CommandBarButton.modified">
|
||||||
|
<Setter Property="Background" Value="#FFF0C2" />
|
||||||
|
<Setter Property="Foreground" Value="#663C00" />
|
||||||
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
|
</Style>
|
||||||
|
</ContentPage.Styles>
|
||||||
|
<Grid
|
||||||
|
RowDefinitions="Auto,Auto,*,Auto"
|
||||||
Margin="5,5,10,5"
|
Margin="5,5,10,5"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch">
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
|
||||||
VerticalScrollBarVisibility="Auto"
|
|
||||||
BringIntoViewOnFocusChange="False">
|
|
||||||
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
|
||||||
|
|
||||||
<WrapPanel x:Name="BlogsHeaderPanel"
|
<WrapPanel x:Name="BlogsHeaderPanel"
|
||||||
Margin="5"
|
Margin="5"
|
||||||
|
|
@ -32,36 +35,48 @@
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="20"
|
FontSize="20"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="Blog Posts" />
|
Text="Billets de blog" />
|
||||||
<TextBox
|
<TextBox
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
PlaceholderText="Filter by title or author..."
|
PlaceholderText="Rechercher un titre ou un auteur..."
|
||||||
Text="{Binding SearchText, Mode=TwoWay}" />
|
Text="{Binding SearchText, Mode=TwoWay}" />
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
<CommandBar x:Name="BlogsCommandBar"
|
<CommandBar x:Name="BlogsCommandBar"
|
||||||
|
Grid.Row="1"
|
||||||
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
||||||
<CommandBar.PrimaryCommands>
|
<CommandBar.PrimaryCommands>
|
||||||
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
|
<CommandBarButton Command="{Binding RefreshCommand}" Label="Actualiser">
|
||||||
<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"
|
||||||
Command="{Binding SaveAsync}"
|
IsVisible="{Binding IsEditing}"
|
||||||
|
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 DeleteAsync}" Label="Delete">
|
<CommandBarButton Command="{Binding DeleteCommand}" IsVisible="{Binding IsEditing}" Label="Supprimer">
|
||||||
<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}" />
|
||||||
|
|
@ -69,6 +84,7 @@
|
||||||
</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>
|
||||||
|
|
@ -84,6 +100,7 @@
|
||||||
</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é">
|
||||||
|
|
@ -91,13 +108,14 @@
|
||||||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||||
</CommandBarToggleButton.Icon>
|
</CommandBarToggleButton.Icon>
|
||||||
</CommandBarToggleButton>
|
</CommandBarToggleButton>
|
||||||
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Aperçu">
|
<CommandBarToggleButton IsVisible="{Binding IsEditing}" 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>
|
||||||
|
|
@ -107,26 +125,45 @@
|
||||||
</CommandBar.PrimaryCommands>
|
</CommandBar.PrimaryCommands>
|
||||||
</CommandBar>
|
</CommandBar>
|
||||||
|
|
||||||
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid" MinHeight="500">
|
<Grid Grid.Row="2" Margin="12" RowSpacing="12" x:Name="MainGrid">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="4" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="2*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Border
|
<Border
|
||||||
Grid.Row="1"
|
Grid.Row="0"
|
||||||
Padding="8"
|
Padding="8"
|
||||||
BorderBrush="Gray"
|
BorderBrush="Gray"
|
||||||
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">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" />
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="{Binding Title}" />
|
||||||
|
<Border
|
||||||
|
x:Name="PublishedBadge"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Padding="6,2"
|
||||||
|
Background="#F2F7F2"
|
||||||
|
BorderBrush="#2F6D2F"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="6"
|
||||||
|
IsVisible="{Binding IsPublished}">
|
||||||
|
<TextBlock
|
||||||
|
FontSize="10"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#2F6D2F"
|
||||||
|
Text="Publié" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="10"
|
FontSize="10"
|
||||||
Foreground="Gray"
|
Foreground="Gray"
|
||||||
|
|
@ -141,7 +178,13 @@
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid
|
<GridSplitter
|
||||||
|
Grid.Row="1"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
ResizeDirection="Rows" />
|
||||||
|
|
||||||
|
<Grid x:Name="PostEditor"
|
||||||
|
IsVisible="{Binding IsEditing}"
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
|
|
@ -193,10 +236,34 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
</Grid>
|
||||||
|
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
|
||||||
|
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
|
||||||
|
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<WrapPanel Grid.Row="1" Orientation="Horizontal"
|
||||||
|
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
|
||||||
|
<TextBlock Text="{Binding ReaderDateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
|
||||||
|
Opacity="0.65" />
|
||||||
|
</WrapPanel>
|
||||||
|
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
|
||||||
|
AssetPathRoot="{Binding ReaderBaseUrl}"
|
||||||
|
Markdown="{Binding ReaderArticle}"
|
||||||
|
SaveScrollValueWhenContentUpdated="False">
|
||||||
|
<reader:MarkdownScrollViewer.Plugins>
|
||||||
|
<reader:MdAvPlugins>
|
||||||
|
<reader:MdAvPlugins.PathResolver>
|
||||||
|
<services:BlogImageResolver />
|
||||||
|
</reader:MdAvPlugins.PathResolver>
|
||||||
|
</reader:MdAvPlugins>
|
||||||
|
</reader:MarkdownScrollViewer.Plugins>
|
||||||
|
</reader:MarkdownScrollViewer>
|
||||||
|
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
|
||||||
|
Text="Choisissez un billet dans la liste pour le lire."
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
<postitControls:StatusBar Grid.Row="3" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||||
</ScrollViewer>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,40 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
private void UpdateHyperlinkCommand()
|
||||||
|
{
|
||||||
|
if (ArticleReader.Engine is IMarkdownEngine2 engine)
|
||||||
|
{
|
||||||
|
if (DataContext is ViewModels.BlogsViewModel vm)
|
||||||
|
engine.HyperlinkCommand = vm.OpenResourceCommand;
|
||||||
|
else
|
||||||
|
engine.HyperlinkCommand = DisabledLinkCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnApplyTemplate(e);
|
base.OnApplyTemplate(e);
|
||||||
if (DataContext is ViewModels.BlogsViewModel vm)
|
if (DataContext is ViewModels.BlogsViewModel vm)
|
||||||
{
|
{
|
||||||
if (!vm.IsLoaded)
|
await vm.InitializeAsync();
|
||||||
{
|
|
||||||
vm.RefreshAsync().Wait();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +65,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
Normal file
19
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<ContentPage
|
||||||
|
x:Class="PostIt.Views.Blogs.VideoPage"
|
||||||
|
xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
x:DataType="vm:VideoPageViewModel">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="16" RowSpacing="12">
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="22" TextWrapping="Wrap" />
|
||||||
|
<Border Grid.Row="1" Background="Black">
|
||||||
|
<vlc:VideoView x:Name="Video" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="12">
|
||||||
|
<Button x:Name="PlayButton" Content="Lire / Pause" Click="PlayPause_Click" />
|
||||||
|
<Button Content="Ouvrir dans le navigateur" Click="OpenExternal_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="PlaybackStatus" Grid.Row="3" TextWrapping="Wrap" />
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
108
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
Normal file
108
src/PostIt/PostIt/Views/Blogs/VideoPage.axaml.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LibVLCSharp.Shared;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
namespace PostIt.Views.Blogs;
|
||||||
|
|
||||||
|
public partial class VideoPage : ContentPage
|
||||||
|
{
|
||||||
|
private LibVLC? _libVlc;
|
||||||
|
private MediaPlayer? _player;
|
||||||
|
private Media? _media;
|
||||||
|
|
||||||
|
public VideoPage() => InitializeComponent();
|
||||||
|
|
||||||
|
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnNavigatedTo(e);
|
||||||
|
ReleasePlayer();
|
||||||
|
if (DataContext is not VideoPageViewModel vm)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core.Initialize();
|
||||||
|
_libVlc = new LibVLC();
|
||||||
|
_player = new MediaPlayer(_libVlc);
|
||||||
|
_player.EncounteredError += Player_EncounteredError;
|
||||||
|
_media = new Media(_libVlc, vm.Source);
|
||||||
|
Video.MediaPlayer = _player;
|
||||||
|
PlayButton.IsEnabled = true;
|
||||||
|
PlaybackStatus.Text = vm.MimeType;
|
||||||
|
if (!_player.Play(_media))
|
||||||
|
PlaybackStatus.Text = "Impossible de démarrer la vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is VLCException or DllNotFoundException or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException or PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
ReleasePlayer();
|
||||||
|
PlaybackStatus.Text = $"Lecture vidéo indisponible : {ex.Message}. Vérifiez l'installation de LibVLC.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnNavigatedFrom(NavigatedFromEventArgs e)
|
||||||
|
{
|
||||||
|
ReleasePlayer();
|
||||||
|
base.OnNavigatedFrom(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
ReleasePlayer();
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleasePlayer()
|
||||||
|
{
|
||||||
|
PlayButton.IsEnabled = false;
|
||||||
|
Video.MediaPlayer = null;
|
||||||
|
if (_player is not null)
|
||||||
|
{
|
||||||
|
_player.EncounteredError -= Player_EncounteredError;
|
||||||
|
_player.Stop();
|
||||||
|
_player.Dispose();
|
||||||
|
_player = null;
|
||||||
|
}
|
||||||
|
_media?.Dispose();
|
||||||
|
_media = null;
|
||||||
|
_libVlc?.Dispose();
|
||||||
|
_libVlc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Player_EncounteredError(object? sender, EventArgs e)
|
||||||
|
=> Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(sender, _player))
|
||||||
|
PlaybackStatus.Text = "Impossible de lire cette vidéo. Vous pouvez l'ouvrir dans le navigateur.";
|
||||||
|
});
|
||||||
|
|
||||||
|
private void PlayPause_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_player is null || _media is null)
|
||||||
|
return;
|
||||||
|
if (_player.IsPlaying)
|
||||||
|
_player.Pause();
|
||||||
|
else if (!_player.Play())
|
||||||
|
PlaybackStatus.Text = "Impossible de reprendre la vidéo.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OpenExternal_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not VideoPageViewModel vm)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this)
|
||||||
|
?? throw new InvalidOperationException("La fenêtre de lecture est indisponible.");
|
||||||
|
if (!await topLevel.Launcher.LaunchUriAsync(vm.Source))
|
||||||
|
PlaybackStatus.Text = "Impossible d'ouvrir le navigateur.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PlaybackStatus.Text = $"Impossible d'ouvrir le navigateur : {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,20 +2,50 @@
|
||||||
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>
|
||||||
<StackPanel HorizontalAlignment="Center"
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||||
VerticalAlignment="Center"
|
<StackPanel Margin="16" Spacing="12" MaxWidth="720"
|
||||||
Spacing="12">
|
HorizontalAlignment="Stretch">
|
||||||
<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"
|
||||||
|
|
@ -61,4 +91,5 @@
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -8,4 +9,12 @@ 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
#nullable enable annotations
|
#nullable enable annotations
|
||||||
|
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Yavsc.Blogspot;
|
namespace Yavsc.Blogspot;
|
||||||
|
|
||||||
public class BlogPostDto : IBlogPost
|
public class BlogPostDto : IBlogPost, INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private bool _isPublished;
|
||||||
|
|
||||||
public string AuthorId { get; set; }
|
public string AuthorId { get; set; }
|
||||||
|
|
||||||
public BlogPostAuthorDto? Author { get; set; }
|
public BlogPostAuthorDto? Author { get; set; }
|
||||||
|
|
@ -30,7 +34,18 @@ public class BlogPostDto : IBlogPost
|
||||||
/// follow-up request. Toggled via
|
/// follow-up request. Toggled via
|
||||||
/// <c>PUT /api/BlogApi/{id}/publish</c>.
|
/// <c>PUT /api/BlogApi/{id}/publish</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPublished { get; set; }
|
public bool IsPublished
|
||||||
|
{
|
||||||
|
get => _isPublished;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isPublished == value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isPublished = value;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsPublished)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public virtual bool AuthorizeCircle(long circleId)
|
public virtual bool AuthorizeCircle(long circleId)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ 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,6 +49,7 @@ 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;
|
||||||
|
|
@ -57,7 +58,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,6 +42,10 @@ 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>
|
||||||
|
|
|
||||||
61
src/Yavsc.Api.Client/NotificationsApiClient.cs
Normal file
61
src/Yavsc.Api.Client/NotificationsApiClient.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin HTTP client for the notifications exposed to PostIt on the home page.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NotificationsApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = Constants.NotificationsPath;
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Func<string> _businessBaseAddress;
|
||||||
|
|
||||||
|
public NotificationsApiClient(IYavscApiClient api, string businessBaseAddress)
|
||||||
|
: this(api, () => businessBaseAddress)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotificationsApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
|
||||||
|
_ = ResolveBusinessBaseAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Notification>> GetCurrentAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync<List<Notification>>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
Absolute($"{PathPrefix}/{Constants.NotificationsCurrentUserRoute}"),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Notification>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return _api.CallAsync<List<Notification>>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
Absolute(PathPrefix),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
|
||||||
|
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
|
||||||
|
|
||||||
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
{
|
||||||
|
var raw = _businessBaseAddress();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
throw new InvalidOperationException("Business base address is required.");
|
||||||
|
|
||||||
|
return new Uri(raw, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Abstract.Models.Messaging;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
namespace Yavsc.ApiControllers.Administration;
|
||||||
|
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route(Constants.APIPrefix + "/" + Constants.NotificationsPath)]
|
||||||
|
public sealed class NotificationsApiController : Controller
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _dbContext;
|
||||||
|
|
||||||
|
public NotificationsApiController(ApplicationDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[HttpGet(Constants.NotificationsCurrentUserRoute)]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<List<Notification>>> GetCurrentUserNotifications()
|
||||||
|
{
|
||||||
|
var currentUserId = User.GetUserId();
|
||||||
|
var isAdmin = User.IsInRole(Constants.AdminGroupName);
|
||||||
|
|
||||||
|
var dismissedIds = string.IsNullOrWhiteSpace(currentUserId)
|
||||||
|
? new HashSet<long>()
|
||||||
|
: await _dbContext.DismissClicked
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(d => d.UserId == currentUserId)
|
||||||
|
.Select(d => d.NotificationId)
|
||||||
|
.ToHashSetAsync();
|
||||||
|
|
||||||
|
var notifications = await _dbContext.Notification
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(n => !dismissedIds.Contains(n.Id))
|
||||||
|
.Where(n =>
|
||||||
|
string.IsNullOrWhiteSpace(n.Target)
|
||||||
|
|| (n.Target != null && n.Target.StartsWith("pub/"))
|
||||||
|
|| (!string.IsNullOrWhiteSpace(currentUserId) && n.Target == $"user/{currentUserId}")
|
||||||
|
|| (isAdmin && n.Target == "administration"))
|
||||||
|
.OrderByDescending(n => n.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(notifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -322,6 +322,8 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,12 +384,9 @@ 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();
|
||||||
|
|
||||||
|
|
@ -414,6 +413,9 @@ 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);
|
||||||
|
|
@ -424,6 +426,12 @@ 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
|
||||||
|
|
@ -441,9 +449,8 @@ 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 disambiguates which side of the estimate signed
|
/// field must match the authenticated caller and identifies
|
||||||
/// because the bearer token belongs to the PostIt OAuth
|
/// which party of the estimate signed.
|
||||||
/// client, not the end user.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SignatureSubmission
|
public class SignatureSubmission
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#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;
|
||||||
|
|
@ -10,6 +12,7 @@ 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;
|
||||||
|
|
@ -26,6 +29,7 @@ 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
|
||||||
|
|
@ -45,11 +49,13 @@ 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;
|
||||||
|
|
@ -233,6 +239,7 @@ 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
|
||||||
|
|
@ -299,7 +306,11 @@ 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
|
||||||
|
|
@ -323,6 +334,9 @@ 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,6 +10,8 @@ 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;
|
||||||
|
|
||||||
|
|
@ -19,10 +21,12 @@ 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)
|
public RdvQueryApiController(ApplicationDbContext context, IYavscMessageSender messageSender)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_messageSender = messageSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -93,6 +97,16 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -124,6 +138,9 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
namespace Yavsc.Blogs;
|
|
||||||
|
|
||||||
public static class BlogConstants
|
|
||||||
{
|
|
||||||
public const string AdminRole = "Admin";
|
|
||||||
public const string ModeratorRole = "Moderator";
|
|
||||||
public const string UserRole = "User";
|
|
||||||
}
|
|
||||||
|
|
@ -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.Blog, ownerUserName, file.Path ?? "");
|
var physicalPath = Path.Combine(siteSettings.UserFilesRoot, 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,7 +9,6 @@ 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
|
||||||
|
|
@ -85,9 +84,9 @@ internal class Program
|
||||||
})
|
})
|
||||||
.Configure<SiteSettings>(settings =>
|
.Configure<SiteSettings>(settings =>
|
||||||
{
|
{
|
||||||
if (settings.Blog == null)
|
if (settings.UserFilesRoot == null)
|
||||||
{
|
{
|
||||||
settings.Blog = "blog";
|
settings.UserFilesRoot = "blog";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -98,11 +97,11 @@ internal class Program
|
||||||
{
|
{
|
||||||
if (app.Environment.IsDevelopment())
|
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"
|
||||||
],
|
],
|
||||||
"Blog": "blog"
|
"UserFilesRoot": "files"
|
||||||
},
|
},
|
||||||
"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.Blog,
|
Path.Combine(_siteSettings.UserFilesRoot,
|
||||||
oldUserName));
|
oldUserName));
|
||||||
var newdir = Path.Combine(_siteSettings.Blog,
|
var newdir = Path.Combine(_siteSettings.UserFilesRoot,
|
||||||
model.UserName);
|
model.UserName);
|
||||||
if (userdirinfo.Exists)
|
if (userdirinfo.Exists)
|
||||||
userdirinfo.MoveTo(newdir);
|
userdirinfo.MoveTo(newdir);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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;
|
||||||
|
|
@ -19,14 +20,20 @@ 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>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,6 +99,16 @@ 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,7 +1046,6 @@ 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();
|
||||||
|
|
@ -1267,7 +1266,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
|
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
|
||||||
}
|
}
|
||||||
if (settings.Blog == null)
|
if (settings.UserFilesRoot == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
|
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
|
||||||
}
|
}
|
||||||
|
|
@ -1284,7 +1283,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
||||||
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
|
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
|
var userFilesDirInfo = new DirectoryInfo(settings.UserFilesRoot);
|
||||||
|
|
||||||
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,6 @@ 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.Blog ?? throw new Exception("blogsDir is not set.");
|
string blogsDir = siteSettings.Value.UserFilesRoot ?? throw new Exception("blogsDir is not set.");
|
||||||
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
|
string 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.Blog,
|
siteSettings.UserFilesRoot,
|
||||||
uid, dirname));
|
uid, dirname));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"Avatars": "avatars",
|
"Avatars": "avatars",
|
||||||
"Quota": 200000000,
|
"Quota": 200000000,
|
||||||
"Bills": "bills",
|
"Bills": "bills",
|
||||||
"Blog": "blog",
|
"UserFilesRoot": "files",
|
||||||
"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.Blog, user.GetUserName());
|
var root = Path.Combine(siteSettings.UserFilesRoot, 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.Blog, user.UserName);
|
var root = Path.Combine(siteSettings.UserFilesRoot, 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.Blog, user.UserName);
|
var root = Path.Combine(siteSettings.UserFilesRoot, 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.Blog, user.UserName);
|
var root = Path.Combine(siteSettings.UserFilesRoot, 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.Blog, user.UserName);
|
var root = Path.Combine(siteSettings.UserFilesRoot, 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.Blog, user.UserName);
|
var root = Path.Combine(siteSettings.UserFilesRoot, 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.Blog, userId, subdir);
|
UserDirectoryInfo di = new UserDirectoryInfo(settings.UserFilesRoot, userId, subdir);
|
||||||
return di;
|
return di;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
29
src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs
Normal file
29
src/Yavsc.Server/Helpers/RdvQueryEventHelpers.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Yavsc.Abstract.Identity;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Messaging;
|
||||||
|
using Yavsc.Models.Workflow;
|
||||||
|
|
||||||
|
namespace Yavsc.Helpers;
|
||||||
|
|
||||||
|
public static class RdvQueryEventHelpers
|
||||||
|
{
|
||||||
|
public static RdvQueryEvent CreateEvent(this RdvQuery query, string subtopic)
|
||||||
|
=> new(subtopic)
|
||||||
|
{
|
||||||
|
Sender = query.ClientId,
|
||||||
|
Reason = query.Reason,
|
||||||
|
Client = new ClientProviderInfo
|
||||||
|
{
|
||||||
|
UserName = query.Client.UserName,
|
||||||
|
UserId = query.ClientId,
|
||||||
|
Avatar = query.Client.Avatar
|
||||||
|
},
|
||||||
|
Previsional = query.Provisional,
|
||||||
|
EventDate = query.EventDate,
|
||||||
|
Location = query.Location,
|
||||||
|
Id = query.Id,
|
||||||
|
ActivityCode = query.ActivityCode,
|
||||||
|
BillingCode = BillingCodes.Rdv
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
using Yavsc.Interfaces.Workflow;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
|
namespace Yavsc.Models.Messaging;
|
||||||
|
|
||||||
|
public sealed class ClientEstimateSignedEvent : IEvent
|
||||||
|
{
|
||||||
|
public ClientEstimateSignedEvent(Estimate estimate, string sender)
|
||||||
|
{
|
||||||
|
EstimateId = estimate.Id;
|
||||||
|
EstimateTitle = estimate.Title;
|
||||||
|
PerformerId = estimate.OwnerId;
|
||||||
|
Sender = sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long EstimateId { get; }
|
||||||
|
public string EstimateTitle { get; }
|
||||||
|
public string PerformerId { get; }
|
||||||
|
public string Topic => "ClientEstimateSigned";
|
||||||
|
public string Sender { get; set; }
|
||||||
|
public string Title => "Devis signé par le client";
|
||||||
|
public string CreateBody() => $"{Sender} a signé le devis n°{EstimateId} : {EstimateTitle}.";
|
||||||
|
}
|
||||||
|
|
@ -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.Blog == null)
|
if (siteSettings.Value.UserFilesRoot == 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.Blog,
|
siteSettings.UserFilesRoot,
|
||||||
user.UserName,
|
user.UserName,
|
||||||
blogFilesSubdir);
|
blogFilesSubdir);
|
||||||
var di = new DirectoryInfo(destDir);
|
var di = new DirectoryInfo(destDir);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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
|
||||||
{
|
{
|
||||||
|
|
@ -30,7 +31,7 @@ namespace Yavsc.Services
|
||||||
IHubContext<ChatHub> hubContext
|
IHubContext<ChatHub> hubContext
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_logger = loggerFactory.CreateLogger<MailSender>();
|
_logger = loggerFactory.CreateLogger<YavscMessageSender>();
|
||||||
_emailSender = emailSender;
|
_emailSender = emailSender;
|
||||||
siteSettings = sitesOptions?.Value;
|
siteSettings = sitesOptions?.Value;
|
||||||
this.hubContext = hubContext;
|
this.hubContext = hubContext;
|
||||||
|
|
@ -70,34 +71,51 @@ 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;
|
||||||
}
|
}
|
||||||
if (!user.EmailConfirmed)
|
|
||||||
{
|
|
||||||
response.failure++;
|
|
||||||
result.error = "user has not confirmed his email address.";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (user.Email == null)
|
|
||||||
{
|
|
||||||
response.failure++;
|
|
||||||
result.error = "user has no legacy email address.";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = ev.CreateBody();
|
var body = ev.CreateBody();
|
||||||
|
var subject = ev is ClientEstimateSignedEvent signed
|
||||||
|
? signed.Title
|
||||||
|
: $"{ev.Sender} (un client) vous demande un rendez-vous";
|
||||||
|
|
||||||
|
if (ev is RdvQueryEvent || ev is ClientEstimateSignedEvent)
|
||||||
|
{
|
||||||
|
_dbContext.Notification.Add(new Notification
|
||||||
|
{
|
||||||
|
title = subject.Length > 1024 ? subject[..1024] : subject,
|
||||||
|
body = body.Length > 512 ? body[..512] : body,
|
||||||
|
Target = $"user/{userId}",
|
||||||
|
tag = ev switch
|
||||||
|
{
|
||||||
|
ClientEstimateSignedEvent estimateEvent => $"estimate/{estimateEvent.EstimateId}/client-signed",
|
||||||
|
RdvQueryEvent rdvEvent => $"rdv/{rdvEvent.Id}",
|
||||||
|
_ => throw new InvalidOperationException("Unsupported persistent notification event.")
|
||||||
|
},
|
||||||
|
click_action = "Fermer"
|
||||||
|
});
|
||||||
|
await _dbContext.SaveChangesAsync(ev.Sender);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogDebug($"Sending to {user.UserName} <{user.Email}> : {body}");
|
if (!user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email))
|
||||||
|
{
|
||||||
|
response.failure++;
|
||||||
|
result.error = !user.EmailConfirmed
|
||||||
|
? "user has not confirmed his email address."
|
||||||
|
: "user has no legacy email address.";
|
||||||
|
_logger.LogWarning("Email notification not sent to {UserId}: {Reason}", userId, result.error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
result.message_id = await _emailSender.SendEmailAsync(user.UserName, user.Email,
|
||||||
$"{ev.Sender} (un client) vous demande un rendez-vous",
|
subject,
|
||||||
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)
|
||||||
|
|
@ -125,7 +143,6 @@ 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 Blog { get; set; }
|
public string UserFilesRoot { get; set; }
|
||||||
public string Bills { get; set; }
|
public string Bills { get; set; }
|
||||||
public string GitRepository { get; set; }
|
public string GitRepository { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Yavsc.Api.Test.Fixtures;
|
||||||
|
using Yavsc.ApiControllers;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test;
|
||||||
|
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class BillingSignatureNotificationTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("bob", "bob", true)]
|
||||||
|
[InlineData("alice", "alice", false)]
|
||||||
|
[InlineData("alice", "bob", false)]
|
||||||
|
public async Task Json_signature_notifies_only_when_the_authenticated_client_signs(
|
||||||
|
string caller, string signer, bool notifies)
|
||||||
|
{
|
||||||
|
fixture.ResetAndSeedActivityGraph();
|
||||||
|
using var scope = fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var estimate = new Estimate
|
||||||
|
{
|
||||||
|
ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
|
||||||
|
Title = "Devis notification", Description = "Devis de test",
|
||||||
|
AttachedFiles = new List<string>(), AttachedGraphics = new List<string>()
|
||||||
|
};
|
||||||
|
db.Estimates.Add(estimate);
|
||||||
|
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
var controller = ActivatorUtilities.CreateInstance<BillingController>(scope.ServiceProvider);
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", caller) }, "test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
controller.Url = new SignatureUrlHelper();
|
||||||
|
var result = await controller.Sign(estimate.Id, new SignatureSubmission
|
||||||
|
{
|
||||||
|
SignerUserId = signer, CoordinateMax = 10000, Strokes = new[] { 1, 5000, 5000 }
|
||||||
|
}, TestContext.Current.CancellationToken);
|
||||||
|
if (caller != signer)
|
||||||
|
{
|
||||||
|
Assert.IsType<ForbidResult>(result);
|
||||||
|
Assert.Empty(db.Signatures);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
|
||||||
|
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||||
|
if (notifies)
|
||||||
|
{
|
||||||
|
Assert.Equal("user/alice", Assert.Single(db.Notification).Target);
|
||||||
|
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
|
||||||
|
Assert.NotEqual(default, estimate.ClientValidationDate);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.Empty(db.Notification);
|
||||||
|
Assert.Empty(delivery.Emails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SignatureUrlHelper : IUrlHelper
|
||||||
|
{
|
||||||
|
public ActionContext ActionContext => throw new NotSupportedException();
|
||||||
|
public string? Action(UrlActionContext actionContext) => "/api/v1/bill/estimate/sign";
|
||||||
|
public string? Content(string? contentPath) => throw new NotSupportedException();
|
||||||
|
public bool IsLocalUrl(string? url) => throw new NotSupportedException();
|
||||||
|
public string? Link(string? routeName, object? values) => throw new NotSupportedException();
|
||||||
|
public string? RouteUrl(UrlRouteContext routeContext) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ 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;
|
||||||
|
|
||||||
|
|
@ -64,8 +67,13 @@ 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.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
||||||
|
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||||
|
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||||
|
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||||
|
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||||
builder.Services.AddAuthorization(options =>
|
builder.Services.AddAuthorization(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AdministratorOnly", policy =>
|
options.AddPolicy("AdministratorOnly", policy =>
|
||||||
|
|
@ -204,23 +212,9 @@ 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>();
|
||||||
|
|
||||||
|
|
@ -334,6 +328,8 @@ 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>());
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Yavsc.Interface;
|
||||||
|
using Yavsc.Server.Hubs;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Test.Fixtures;
|
||||||
|
|
||||||
|
public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<ChatHub>, IHubClients
|
||||||
|
{
|
||||||
|
public record Email(string Recipient, string Subject, string Body);
|
||||||
|
public record Push(string ConnectionId, string Method, object?[] Arguments);
|
||||||
|
|
||||||
|
public ConcurrentQueue<Email> Emails { get; } = new();
|
||||||
|
public ConcurrentQueue<Push> Pushes { get; } = new();
|
||||||
|
IHubClients IHubContext<ChatHub>.Clients => this;
|
||||||
|
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
|
||||||
|
public IClientProxy All => throw new NotSupportedException();
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
|
||||||
|
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
|
||||||
|
public IClientProxy Group(string groupName) => throw new NotSupportedException();
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
|
||||||
|
public IClientProxy User(string userId) => throw new NotSupportedException();
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
|
||||||
|
{
|
||||||
|
Emails.Enqueue(new Email(email, subject, htmlMessage));
|
||||||
|
return Task.FromResult(Guid.NewGuid().ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Emails.Clear();
|
||||||
|
Pushes.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -138,6 +139,8 @@ 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]
|
||||||
|
|
@ -165,6 +168,8 @@ 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]
|
||||||
|
|
@ -192,6 +197,48 @@ 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,6 +5,9 @@ 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;
|
||||||
|
|
||||||
|
|
@ -34,6 +37,72 @@ 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,6 +4,7 @@ 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;
|
||||||
|
|
@ -98,6 +99,66 @@ 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,8 +91,12 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
return file.Id;
|
return file.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
[InlineData("note.txt", "note.txt")]
|
||||||
|
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
|
||||||
|
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
|
||||||
|
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller(
|
||||||
|
string originalName, string storedName)
|
||||||
{
|
{
|
||||||
_fixture.ResetDatabase();
|
_fixture.ResetDatabase();
|
||||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||||
|
|
@ -105,7 +109,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", "note.txt");
|
form.Add(fileContent, "file", originalName);
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -117,18 +121,17 @@ 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("note.txt", item.GetProperty("fileName").GetString());
|
Assert.Equal(storedName, 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("note.txt", row.Path);
|
Assert.Equal(storedName, 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);
|
||||||
|
|
||||||
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", storedName)),
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
76
src/test/Yavsc.Blogs.Tests/FilterFileNameTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
public sealed class FilterFileNameTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("note.txt", "note.txt")]
|
||||||
|
[InlineData("ABC-123=_~.PDF", "ABC-123=_~.PDF")]
|
||||||
|
[InlineData("Troisi\u00e8me.PDF", "Troisi_232me.PDF")]
|
||||||
|
[InlineData("\u00e9\u00e0\u00e7.pdf", "_233_224_231.pdf")]
|
||||||
|
[InlineData("folder/file\\name.pdf", "folder_047file_092name.pdf")]
|
||||||
|
public void Filter_encodes_unsupported_characters_without_URL_fragments(
|
||||||
|
string originalName, string expectedName)
|
||||||
|
{
|
||||||
|
var filtered = AbstractFileSystemHelpers.FilterFileName(originalName);
|
||||||
|
|
||||||
|
Assert.Equal(expectedName, filtered);
|
||||||
|
Assert.DoesNotContain("#", filtered);
|
||||||
|
Assert.True(filtered.IsValidYavscPath());
|
||||||
|
Assert.Equal(filtered, AbstractFileSystemHelpers.FilterFileName(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Uploaded_accented_filename_is_accessible_through_static_files()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"yavsc-filename-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = System.Text.Encoding.UTF8.GetBytes("accented filename regression");
|
||||||
|
using var input = new MemoryStream(payload);
|
||||||
|
var file = new FormFile(input, 0, payload.Length, "file",
|
||||||
|
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF")
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "application/pdf"
|
||||||
|
};
|
||||||
|
var user = new ApplicationUser { DiskQuota = 10_000_000 };
|
||||||
|
var received = user.ReceiveUserFile(root, file);
|
||||||
|
Assert.False(received.QuotaOffense);
|
||||||
|
Assert.Equal("INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF",
|
||||||
|
received.FileName);
|
||||||
|
|
||||||
|
using var provider = new PhysicalFileProvider(root);
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
builder.WebHost.UseTestServer();
|
||||||
|
await using var app = builder.Build();
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = provider,
|
||||||
|
RequestPath = "/files"
|
||||||
|
});
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
using var http = app.GetTestClient();
|
||||||
|
var uri = new Uri(http.BaseAddress!, $"/files/{received.FileName}");
|
||||||
|
Assert.Equal(string.Empty, uri.Fragment);
|
||||||
|
using var response = await http.GetAsync(uri, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
|
||||||
|
Assert.Equal(payload,
|
||||||
|
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.Blog = BlogFilesRoot;
|
s.UserFilesRoot = 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.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
s.UserFilesRoot = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<BlogSpotService>();
|
builder.Services.AddScoped<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",
|
||||||
"Blog": "blog",
|
"UserFilesRoot": "files",
|
||||||
"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.Blog = "server-tests-files-root");
|
services.Configure<SiteSettings>(s => s.UserFilesRoot = "server-tests-files-root");
|
||||||
|
|
||||||
// Trivial file-system auth: Details never calls into it, but
|
// 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