diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 140472838..e85a9b167 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -27,7 +27,7 @@ jobs: build: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 steps: - name: Clone yavsc run: | @@ -50,9 +50,6 @@ jobs: echo "🏗️ Building the solution..." cd /src/_src && dotnet build --verbosity normal - name: Test - artifacts: - name: test-results - path: test-results.html run: | echo "🚀 Lancement des tests..." cd /src/_src && dotnet test \ diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7d0670a17..de61bd43d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -52,7 +52,7 @@ jobs: release: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 steps: - name: Clone du repo au tag demandé env: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..52d500e18 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index d8466bdb8..2d84b767f 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -8,8 +8,7 @@ on: jobs: production-deploy: - runs-on: ubuntu-latest - + runs-on: ubuntu-26.04 steps: # 1. Récupération du code - name: Check out the repo @@ -25,8 +24,24 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 10.0.x + - name: Workloads restore + run: dotnet workload restore - name: Test - run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" + env: + TEST_FAILED: "" + run: make test || export TEST_FAILED=$? + - uses: actions/upload-artifact@v7 + with: + name: my-artifact + path: test-reports/**/* + - name: Fail if tests failed + if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }} + run: exit $TEST_FAILED + # 4. Build et Push de l'image de production finale - name: Build and push production image uses: docker/build-push-action@v7 diff --git a/.gitignore b/.gitignore index 32d9c064d..57f13079f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ DataDir/ # Exclude test reports (local test report, not for the repo) /test-reports +/doc/**/.*.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 7732a77b1..e6f2aee1d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,5 +46,9 @@ } }, "dotnet.defaultSolution": "yavsc.sln", - "axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln" + "axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln", + "chat.tools.terminal.autoApprove": { + "dotnet build": true, + "dotnet test": true + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 011dfe449..68f0bc6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,257 @@ # 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 + `/files//`, reconnaissance des fichiers + utilisateur sur cette origine et détection du type MIME par HTTP. +* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop, + avec lecture/pause, libération du lecteur à la fermeture et accès au + navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et + les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système. + +### Changed + +* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`) + +## [1.0.8-rc25] - unstable + +### Added + +* [Yavsc.Api] Notification du performer à la création d'un rendez-vous + depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org. +* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature + client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt + et transmise par e-mail et SignalR. +* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` + pour exposer les notifications publiques et ciblées à PostIt sur la + Home page ; les notifications d'administration restent filtrées. +* [PostIt] Affichage des notifications sur la page d'accueil, avec + actualisation, indicateur de chargement et message d'erreur. Les + notifications publiques sont accessibles sans connexion et la liste + est rechargée lors d'un changement de session. + +### Changed + +* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; + les liens provisoires des nouveaux billets sont résolus au premier + enregistrement. Save conserve l'édition ouverte et est mis en valeur + lorsque le titre, le corps ou les pièces jointes sont modifiés. + +* [Projet] Instructions Copilot persistantes : mise à jour du changelog + par défaut à chaque commit, avec justification explicite des exceptions. + +### Fixed + +* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau + réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché. + Les fenêtres sont fermées après chaque test et les quatre boutons + de navigation sont vérifiés. + +* [PostIt] Les liens Markdown des pièces jointes utilisent désormais + le même filtre de nom que l'upload serveur, avant l'encodage URL. + Le libellé conserve le nom original, notamment ses accents. + +* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés + dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour + éviter les fragments d'URL et permettre le téléchargement via les + fichiers statiques, notamment pour les noms accentués. + +* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont + désormais enregistrés côté serveur pour les requêtes authentifiées : + le middleware de télémétrie est maintenant branché après + l'authentification, ce qui permet de comptabiliser `ApiCalls` et + `BandwidthMb` au lieu d'enregistrer des zéros. + +## [1.0.8-rc24] - unstable + +### Added + +* [PostIt] Reconnection automatique aux salons de discussion au reconnect + +### Changed + +* [PostIt] La liste des billets et l'éditeur Markdown se partagent + l'espace disponible et défilent séparément ; un séparateur permet + de redimensionner les deux zones. + +### Fixed + +* [PostIt] Défilement des articles longs en édition et en aperçu : + suppression de la limite de hauteur qui masquait le contenu Markdown + et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless + couvrent les barres automatiques, le redimensionnement et le + défilement indépendant de la liste. + +## [1.0.8-rc23] - unstable + +### Added + +* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la + liste, mis à jour lors de la publication ou du retour en brouillon. +* [PostIt] Gestion native du compte inspirée de `/Manage` : modification + du nom d'utilisateur, du nom complet et de l'adresse, téléversement + d'avatar, confirmation e-mail, préférence de courriel mensuel, + création ou changement de mot de passe et activation/désactivation + de l'authentification à deux facteurs. +* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des + comptes bancaires, dissociation des connexions externes et suppression + du compte avec confirmation du nom d'utilisateur. +* [PostIt] Configuration du profil professionnel et des activités, + navigation vers les cercles et les publications depuis le compte. +* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche + dans les métadonnées du profil et consultation de la consommation + de ressources. +* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de + ressources, API de synthèse et migration `ResourceUsageRecords`. +* [Tests] Couverture des parcours profil et professionnel, de l'API de + consommation de ressources et du renouvellement des jetons : + rotation à usage unique, appels simultanés, réponses 401 concurrentes + et rejet d'un jeton de renouvellement révoqué. + +### Changed + +* [PostIt] Réorganisation de la page de compte en sections de profil, + sécurité et préférences ; actions de modification désactivées tant + que le profil n'est pas chargé ou qu'une opération est en cours. +* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark` + dans les pages de profil et de configuration professionnelle. +* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par + PostIt, avec l'état de sécurité, les connexions externes, les comptes + bancaires et les compteurs du compte. + +### Fixed + +* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores + EF nécessaires à `UserManager` : leur absence + empêchait l'activation du contrôleur de compte et provoquait une 500. +* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton + ne contient pas d'identifiant utilisateur ou que le compte n'existe + plus, au lieu d'une exception serveur. +* [PostIt] Synchronisation de tous les chemins de renouvellement des + jetons, y compris après une 401 et au démarrage : les appels + concurrents ne réutilisent plus le même `refresh_token`. Un rejet + `invalid_grant` efface la session et invite à se reconnecter. +* [PostIt] Effacement des informations obsolètes après un échec de + rechargement du profil et normalisation des valeurs de calendrier + lors de la sauvegarde. +* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel + lors de son enregistrement. +* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir + d'un résumé agrégé : opérations dédiées avec contrôle de propriété + et validation de longueur des champs. + +### Known limitations + +* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des + crédits ne sont pas encore disponibles dans l'interface native. + +## [1.0.8-rc22] - unstable + +### Fixed + +User experience login into PostIt + +## [1.0.8-rc21] - unstable + +### Fixed + +User experience editing the blog article. + +## [1.0.8-rc20] - unstable + +Chat SignalR fonctionnel de bout en bout (connexion, salons multiples, +envoi/réception de messages), enregistreur audio et localisation dans +PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx +apprêté pour WebSocket. + +### Added + +* [PostIt] Interface de chat : page `ChatPage` (connexion au hub, + saisie du nom de salon, liste des messages et des membres présents), + entrée depuis la page d'accueil. +* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage` + (Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec + envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en + focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`). +* [PostIt] Modèle multi-salles côté client : `JoinedRooms`, + `SelectedRoom` (salle en focus), messages et utilisateurs indexés par + salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la + salle active ; un `Join` sur une salle déjà jointe ne fait que + changer le focus. +* [PostIt] Enregistreur audio (`AudioRecorder`, interface + `IPCMProcessor`) et ressources de localisation + (`Resources.{resx,en-EN,pt-PT}`). +* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à + chaud ; encore KO). +* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade + WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et + `proxy_buffering off` — sans quoi la connexion SignalR tombait au + premier silence ou l'upgrade échouait derrière nginx. + +### Changed + +* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et + enrichi d'une commande d'action optionnelle (`CheckCommand` + + `CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut + propose désormais l'action contextuelle (Connecter / Déconnecter / + Reconnecter / Vérifier la connexion). +* [PostIt] `ChatViewModel` suit le cycle de vie complet de la + connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de + `WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un + `IsConnected` optimiste. +* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` : + `ChatRoomInfo` n'expose que des champs publics, que System.Text.Json + ne sérialise pas par défaut — le client recevait des objets vides + (`Name` null, `Users` vide) et la liste des salons joints n'était + jamais alimentée. +* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName` + en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le + relire depuis un dictionnaire jamais alimenté. +* [PostIt.Android] `network_security_config.xml` déplacé sous + `Resources/xml/` (chemin canonique Android). + +### Fixed + +* [Yavsc.Server] `KeyNotFoundException` dans + `HubConnectionManager.OnConnected` à chaque connexion SignalR : + `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique + d'un `SetUserName` disparu lors d'une refactorisation. L'exception + dans `OnConnectedAsync` faisait fermer la connexion par le hub, et + PostIt affichait « Déconnecté (The server closed the connection…) » + immédiatement après la connexion. +* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de + `ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à + la racine du service, pas sous le préfixe versionné). +* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"` + sur une grille à 3 rangées (rangée hors limites, bandeau invisible). + +## [1.0.8-rc19] - unstable + +[PostIt] Blogs UX + +### Fixed + +La saisie du billet est maintenant plutôt possible, +en petit écran. + ## [1.0.8-rc18] - unstable -Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout +* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo + +* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout (arbre de stockage indexé par le login, pas par le `sub`), boutons d'action des listes réparés (binding `$parent`), revendication `name` enfin présente dans les access tokens (branchement du `ProfileService`), et négociation HTTP/3 côté client PostIt. + ### Added * [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc : diff --git a/Dockerfile b/Dockerfile index 795b70ab3..8ebbb73f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,41 +24,24 @@ # ===================================================================== # syntax=docker/dockerfile:1.7 -ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1 +ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2 FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env WORKDIR /src -# (1) Manifest + fichiers de projet pour le cache de restore -COPY *.sln ./ -COPY Directory.*.props ./ -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/ -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/ - -# (2) Tout le code source COPY . . -# (4) Restore RUN dotnet restore -# (5) Build des trois services web principaux +# Build des trois services web principaux RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly -# (6) Build APK Android (utilisé par le workflow docker-publish-android). +# Build APK Android (utilisé par le workflow docker-publish-android). ARG ANDROID_TARGET_RID=android-arm64 RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} -# (7) Publish des trois services web (les artifacts sont consommés par +# Publish des trois services web (les artifacts sont consommés par # les stages publish-org / publish-api / publish-blogs ci-dessous). RUN mkdir -p /app/publish RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org diff --git a/Dockerfile.backend b/Dockerfile.backend index a4e9a54a9..46c9a982e 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,36 +1,12 @@ # Utilisation de l'image globale pour le build et l'exécution -FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env +FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env WORKDIR /src - # 1. Copie de la solution -COPY *.sln ./ -COPY Directory.*.props ./ - -# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration -COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/ -COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/ -COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/ -COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/ -COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/ -COPY src/cli/*.csproj ./src/cli/ -COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/ - -# Projets associés à PostIt -COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/ -COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/ -COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/ -COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ - -# 4. Copie de l'intégralité du code source COPY . . - +RUN dotnet workload restore # 4. Restauration des dépendances pour tous les projets RUN dotnet restore - -# 5. Copie de la totalité du code source (filtrée par votre .dockerignore) -COPY . . - # 6. Compilation du serveur Web principal RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly diff --git a/Makefile b/Makefile index 29fd47efa..04efba0be 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,6 @@ DESTDIR=/tmp/yavsc APP_PATH=srv/www/yavsc APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) -include .env - all: dotnet build --nologo @@ -23,8 +21,7 @@ src/Yavsc/bin/output/wwwroot: test: ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \ --report-xunit-html \ - --results-directory "test-reports" \ - --report-xunit-html-filename "test-results.html" + --results-directory "test-reports" test-postit: ASPNETCORE_ENVIRONMENT=Development dotnet test \ diff --git a/README.md b/README.md index fefbdd48d..79e711258 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d * ~/Views/Home/Index.cshtml * ~/Views/Home/About.cshtml +# 👥 Crédits & Co-auteurs + +Ce projet a été développé de manière hybride : +- **Conception, architecture et revue de code :** Paul Schneider +- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf)) + # Remerciements Photo de Dewang Guptasur Unsplash diff --git a/contrib/Makefile b/contrib/Makefile index deb55bcc6..5ccf773a1 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -14,7 +14,7 @@ reinstall: copy-binaries @echo "💯 backend started" reinstall-prod: - make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production + make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production generated/$(APP_INSTALL_PREFIX)yavscOrg.service: generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: diff --git a/contrib/blog-edit.png b/contrib/blog-edit.png new file mode 100644 index 000000000..4175f5153 Binary files /dev/null and b/contrib/blog-edit.png differ diff --git a/contrib/blog-view.png b/contrib/blog-view.png new file mode 100644 index 000000000..04625dbc9 Binary files /dev/null and b/contrib/blog-view.png differ diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json index f522feaca..6f8fa02bc 100644 --- a/contrib/template.appsettings-blogs.json +++ b/contrib/template.appsettings-blogs.json @@ -8,7 +8,7 @@ "CorsAllowedOrigins": [ "https://preprod-api.$DOMAIN" ], - "Blog": "blog" + "UserFilesRoot": "files" }, "ConnectionStrings": { "YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;" diff --git a/contrib/template.appsettings-org.json b/contrib/template.appsettings-org.json index 76203d9e9..c330f5f32 100644 --- a/contrib/template.appsettings-org.json +++ b/contrib/template.appsettings-org.json @@ -30,7 +30,7 @@ "EMail": "paul@pschneider.fr" }, "Quota": 200000000, - "Blog": "blog", + "UserFilesRoot": "files", "Avatars": "avatars", "Bills": "bills", "TempDir": "temp", diff --git a/contrib/template.vhost b/contrib/template.vhost index ea2c2cb6d..b4c9c139e 100644 --- a/contrib/template.vhost +++ b/contrib/template.vhost @@ -57,5 +57,25 @@ server { proxy_read_timeout 100s; proxy_buffering off; } + + # SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout + # long, sinon la connexion tombe au premier silence ou l'upgrade échoue. + # Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui + # exigerait une directive map {} globale hors de ce vhost) : SignalR + # négocie toujours un upgrade sur ce chemin, et la négociation initiale + # (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet + # en-tête côté Kestrel. + location /chathub { + proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_buffering off; + } include server-status.conf; -} \ No newline at end of file +} diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 70f3f2fdc..439bb8f70 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -40,6 +40,51 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop). Toute la logique (VM, services, navigation, settings, OIDC) est dans le code-base partagé. +## Lecture des billets et fichiers utilisateur + +La page des blogs affiche par défaut une vue de lecture : titre, login +de l'auteur, date de modification et contenu Markdown dans une zone +défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et +**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche +le contenu enregistré sans effacer les modifications non enregistrées ; +un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé. + +La base des ressources est dérivée de `Settings.Authentication.Authority` +(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` : +`https:///files//`. Le repli sur `AuthorId` +conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé. +Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ; +un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org. +Les liens absolus ne sont pas réécrits. Les images Markdown `![texte](url)` +sont affichées dans le billet et leur résolveur n'accepte que HTTP(S), +sans accès aux fichiers locaux du poste. +Le 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//...` de la même origine Org +(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec +`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse +`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une +extension connue sert de repli uniquement en l'absence de type exploitable +(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont +affichées dans la barre de statut, sans les masquer par une déduction MIME. + +Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop. +Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et +une action d'ouverture externe. Le lecteur et ses ressources natives sont +arrêtés et libérés à la sortie de la page. **LibVLC doit être installé** +sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les +bibliothèques natives adaptées sont nécessaires sous Windows/macOS). +Une absence de LibVLC ou une erreur de décodage est signalée dans la page. +Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible, +les vidéos sont ouvertes dans le navigateur, comme les autres fichiers +(images liées, PDF, téléchargements) et les liens externes. + ## ViewLocator custo Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un @@ -126,6 +171,20 @@ le DI est construit. Ordre, dans cet ordre : > à cette table, *ne pas* ajouter de registration pour > `Settings` ailleurs que la ligne `AddSingleton(settings)`. +## Défilement de la page des billets + +La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête +et les commandes restent visibles, tandis que la liste des billets et +l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet +de modifier cette répartition. La liste et l'article défilent séparément. + +Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ; +l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter +la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir +dépasser le viewport pour que les barres automatiques s'activent. +Ne pas placer cette grille dans un `StackPanel` vertical ou un +`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée. + ## Navigation Le host de navigation est un `NavigationPage x:Name="NavRoot"` diff --git a/docker-compose.yaml b/docker-compose.yaml index f478dcc9f..ea08e9009 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,7 +26,7 @@ services: dockerfile: Dockerfile target: web-runtime args: - BUILD_ENV_TAG: debian12-dotnet10-android36-v1 + BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -53,9 +53,7 @@ services: depends_on: db: condition: service_healthy - networks: - - yavsc-internal - - yavsc-public + network_mode: host # volumes: # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Sans ce montage, 'docker compose up' échoue côté web avec @@ -73,7 +71,7 @@ services: dockerfile: Dockerfile target: api-runtime args: - BUILD_ENV_TAG: debian12-dotnet10-android36-v1 + BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -90,9 +88,7 @@ services: depends_on: db: condition: service_healthy - networks: - - yavsc-internal - - yavsc-public + network_mode: host # volumes: # - /etc/letsencrypt:/etc/letsencrypt:ro @@ -105,7 +101,7 @@ services: dockerfile: Dockerfile target: blogs-runtime args: - BUILD_ENV_TAG: debian12-dotnet10-android36-v1 + BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -122,9 +118,7 @@ services: depends_on: db: condition: service_healthy - networks: - - yavsc-internal - - yavsc-public + network_mode: host # volumes: # - /etc/letsencrypt:/etc/letsencrypt:ro diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 42d09cd55..f0b523488 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -4,7 +4,7 @@ true - 12.1.2 + 12.1.3 @@ -13,16 +13,22 @@ - + + + - - + + + + + + @@ -31,4 +37,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 549100804..f8fd5a0fd 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -2,6 +2,7 @@ using Android.App; using Android.Content; using Android.Content.PM; +using Android.Views; using AndroidX.Core.Provider; using AndroidX.Emoji2.Text; using Avalonia.Android; @@ -15,7 +16,9 @@ namespace PostIt.Android; Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] + WindowSoftInputMode = SoftInput.AdjustResize, + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode) + ] public class MainActivity : AvaloniaMainActivity { /// diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 8793aae8a..256555df1 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,6 +1,10 @@  - + diff --git a/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml new file mode 100644 index 000000000..6d2c97559 --- /dev/null +++ b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 10.0.2.2 + + diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 46df5025f..1d429de95 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -24,4 +24,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index a2b8cd66f..1c5aa0c27 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -1,6 +1,7 @@ using System; using Avalonia; using PostIt.Services; +using PostIt.CLIOptions; namespace PostIt.Desktop; diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 112c11752..741b3863c 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 946d3d28e..c9d7d3aae 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,8 +20,13 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [Fact] - public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + [InlineData("rapport final.pdf", "rapport%20final.pdf")] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority( + string originalName, string urlName) { const string authority = "https://idp.example.test"; @@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - "note.txt", + originalName, System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); + Assert.Empty(recorder.Calls); + var draftBeforeSave = vm.DraftArticle; + await vm.SaveAsync(); - // The update branch issues two PUTs: the multipart one - // carrying the file, then — because TryAppendAttachmentLinks - // appended a link — a JSON one carrying the final article. - var linkPut = recorder.Calls.LastOrDefault( - c => c.method == HttpMethod.Put); + var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; - var sentStringContent = multipartContent.First() as StringContent ; + var sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + using var document = System.Text.Json.JsonDocument.Parse(sentString); + var article = document.RootElement.GetProperty("article").GetString(); + Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", sentString); + Assert.DoesNotContain("](/files/", article); + Assert.Equal(draftBeforeSave, vm.DraftArticle); + Assert.NotNull(vm.SelectedPost); + Assert.False(vm.IsDraftModified); + } + + [Fact] + public async Task New_post_resolves_only_the_provisional_link_at_first_save() + { + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); + vm.DraftTitle = "Nouveau billet"; + vm.DraftArticle = "Texte conservé."; + vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); + + Assert.Contains("postit-attachment:", vm.DraftArticle); + Assert.Empty(recorder.Calls); + await vm.SaveAsync(); + + Assert.Equal("Nouveau billet", vm.DraftTitle); + Assert.Contains("Texte conservé.", vm.DraftArticle); + Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", + vm.DraftArticle); + Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); + Assert.Equal(42, vm.SelectedPost?.Id); + Assert.Empty(vm.DraftAttachments); + Assert.False(vm.IsDraftModified); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); } } diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs new file mode 100644 index 000000000..b56e88ccf --- /dev/null +++ b/src/PostIt/PostIt.Tests/BlogReaderTests.cs @@ -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("PostReader")!.IsVisible); + Assert.False(page.FindControl("PostEditor")!.IsVisible); + Assert.False(page.SaveButton.IsVisible); + var reader = page.FindControl("ArticleReader")!; + Assert.Equal(vm.ReaderArticle, reader.Markdown); + Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot); + Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom(reader.Engine).HyperlinkCommand); + vm.EditPostCommand.Execute(null); + Assert.False(page.FindControl("PostReader")!.IsVisible); + Assert.True(page.FindControl("PostEditor")!.IsVisible); + Assert.True(page.SaveButton.IsVisible); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public async Task 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("ArticleReader")!; + var engine = Assert.IsAssignableFrom(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(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(() => 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("ArticleReader")!; + var scroll = reader.GetVisualDescendants().OfType().Single(); + Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height); + Assert.True(scroll.Extent.Height > scroll.Viewport.Height); + scroll.ScrollToEnd(); + Assert.True(scroll.Offset.Y > 0); + vm.SelectedPost = null; + Dispatcher.UIThread.RunJobs(); + Assert.Empty(reader.Markdown!); + } + finally + { + window.Close(); + } + } + + [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 ReadAsync(Memory 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((_, _) => new ContentPage()); + app.DataTemplates.Insert(0, template); + try + { + var page = new BlogsPage { DataContext = vm }; + var reader = page.FindControl("ArticleReader")!; + Dispatcher.UIThread.RunJobs(); + var engine = Assert.IsAssignableFrom(reader.Engine); + var link = Assert.IsType(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)"))); + Assert.NotNull(link.Command); + link.Command(link.CommandParameter!); + Assert.NotNull(vm.OpenResourceCommand.ExecutionTask); + await vm.OpenResourceCommand.ExecutionTask; + var target = Assert.IsType(root.NavRoot.NavigationStack.Last().DataContext); + Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri); + Assert.Equal("video/mp4", target.MimeType); + } + finally + { + app.DataTemplates.Remove(template); + } + } + + [AvaloniaFact] + public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control() + { + var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4"); + var page = Assert.IsType(new ViewLocator().Build(vm)); + Assert.NotNull(page.FindControl("Video")); + } + + [Fact] + public async Task Broken_resource_reports_an_error_and_releases_busy_state() + { + using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound))); + var vm = CreateViewModel(new BlogResourceService(client)); + vm.SelectedPost = Post(); + await vm.OpenResourceCommand.ExecuteAsync("absent.mp4"); + Assert.False(vm.IsBusy); + Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity); + } +} diff --git a/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs b/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs new file mode 100644 index 000000000..0682dc6f2 --- /dev/null +++ b/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http.Headers; +using PostIt.Services; + +namespace PostIt.Tests; + +public class BlogResourceServiceTests +{ + private const string Authority = "https://yavsc.pschneider.fr"; + + [Theory] + [InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")] + [InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")] + [InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")] + [InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")] + [InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")] + [InlineData("//elsewhere.test/page", "https://elsewhere.test/page")] + public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected) + => Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri); + + [Fact] + public void Encodes_login_and_keeps_trailing_slash() + => Assert.Equal(Authority + "/files/paul%20test/", + BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri); + + [Theory] + [InlineData(Authority + "/files/paul/movie.mp4", true)] + [InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)] + [InlineData(Authority + "/files/paul/", false)] + [InlineData(Authority + "/files/paul", false)] + [InlineData(Authority + "/files/", false)] + [InlineData(Authority + "/files-other/paul/movie.mp4", false)] + [InlineData(Authority + "/page.mp4", false)] + [InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)] + [InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)] + [InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)] + [InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)] + [InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)] + public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected) + => Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority)); + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("file:///tmp/video.mp4")] + [InlineData("data:video/mp4;base64,AAAA")] + public void Rejects_non_web_links(string link) + => Assert.Throws(() => BlogResourceService.Resolve(link, Authority, "paul")); + + [Theory] + [InlineData("video/webm", "movie.jpg", "video/webm")] + [InlineData("image/jpeg", "movie.mp4", "image/jpeg")] + [InlineData("application/octet-stream", "movie.MP4", "video/mp4")] + [InlineData(null, "movie.webm", "video/webm")] + [InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")] + [InlineData(null, "file.unknown", "application/octet-stream")] + public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected) + { + using var client = new HttpClient(new StubHandler(request => + { + Assert.Equal(HttpMethod.Head, request.Method); + return Response(HttpStatusCode.OK, mime); + })); + var service = new BlogResourceService(client); + Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"), + TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status) + { + var calls = 0; + using var client = new HttpClient(new StubHandler(request => + { + calls++; + if (request.Method == HttpMethod.Head) + return Response(status, null); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("bytes=0-0", request.Headers.Range?.ToString()); + return Response(HttpStatusCode.PartialContent, "video/mp4"); + })); + Assert.Equal("video/mp4", await new BlogResourceService(client) + .GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken)); + Assert.Equal(2, calls); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status) + { + using var client = new HttpClient(new StubHandler(_ => Response(status, null))); + await Assert.ThrowsAsync(() => new BlogResourceService(client) + .GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")] + [InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")] + [InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")] + public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected) + { + using var client = new HttpClient(new StubHandler(request => + { + Assert.Equal(expected, request.RequestUri!.AbsoluteUri); + return Response(HttpStatusCode.OK, "image/jpeg"); + })); + var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" }; + await using var stream = await resolver.ResolveImageResource(link); + Assert.NotNull(stream); + } + + [Theory] + [InlineData("file:///etc/passwd")] + [InlineData("data:image/png;base64,AAAA")] + public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link) + { + var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" }; + await Assert.ThrowsAsync(() => resolver.ResolveImageResource(link)); + } + + private static HttpResponseMessage Response(HttpStatusCode status, string? mime) + { + var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) }; + if (mime is not null) + response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime); + return response; + } + + internal sealed class StubHandler(Func respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(respond(request)); + } +} diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index 17efd391b..31c82387d 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using PostIt.Models; using PostIt.ViewModels; using Yavsc; using Yavsc.Api.Client; diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs new file mode 100644 index 000000000..667ea599a --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs @@ -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 { 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>(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 { 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>(); + transport.Response = oldResponse.Task; + var oldLoad = vm.RefreshNotificationsAsync(); + Assert.True(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Current") }); + await vm.RefreshNotificationsAsync(); + oldResponse.SetResult(new List { 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>(); + 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 { Note(2, "Public") }); + session.SetError("Disconnected"); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.True(transport.WasAnonymous); + + privateResponse.SetResult(new List { 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 { 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().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