diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index ea58d2fe..a0f3a375 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -25,9 +25,9 @@ on: jobs: build: + runs-on: docker - container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 + steps: - name: Clone yavsc run: | @@ -39,13 +39,10 @@ jobs: git checkout FETCH_HEAD fi git submodule update --init --recursive - echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" - + echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" + - name: Restore dependencies + run: cd /src/_src && dotnet restore + - name: Build + run: cd /src/_src && dotnet build --no-restore - name: Test - run: | - echo "🚀 Lancement des tests..." - cd /src/_src && dotnet test \ - --verbosity normal \ - --filter="Category!=Platform-Android" \ - --logger "xunit;LogFileName=test-results.xml" \ - && echo "✅ Success !" || echo "❌ Fail ($?)!" + run: cd /src/_src && dotnet test --no-build --verbosity normal diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a72f92bd..09d9757a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -51,8 +51,6 @@ jobs: # via l'API REST Forgejo (pas d'actions tierces Node). release: runs-on: docker - container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 steps: - name: Clone du repo au tag demandé env: @@ -68,8 +66,10 @@ jobs: # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). cd /src + # Clone unshallow pour que GitVersion.MsBuild ait l'historique + # et les tags (sinon MSB3073 sur la cible Android cf. PR #21). if [[ ! -d _src/.git ]]; then - git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src fi cd _src @@ -171,22 +171,37 @@ jobs: echo "EOF" >> "$GITHUB_ENV" echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - - name: Restore + - name: Build des projets .NET (sans docker) + # L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le + # workload Android, mais PAS le binaire `docker` ni de daemon + # Docker. On exécute donc les commandes dotnet directement + # au lieu de passer par `docker build`. + # Equivalent des stages build-env du Dockerfile (lignes + # restore + build Yavsc.Org + build Yavsc.Api + build + # Yavsc.Blogs + build PostIt.Android -r android-arm64). run: | cd /src/_src dotnet restore + dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release --no-restore -clp:ErrorsOnly -r android-arm64 - - name: Build de PostIt.Android ARM64 + - name: Copier l'APK signé vers un emplacement connu + # Le build Android avec -r android-arm64 produit l'APK dans + # bin/Release/net10.0-android/android-arm64/. On le copie à + # la racine du checkout pour que l'étape d'upload le trouve. run: | cd /src/_src - dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ - -c Release -r android-arm64 --no-restore -clp:ErrorsOnly - - - name: Build de PostIt.Android x64 - run: | - cd /src/_src - dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ - -c Release -r android-x64 --no-restore -clp:ErrorsOnly + APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk + if [[ ! -f "$APK" ]]; then + echo "::error::APK not found at $APK" + ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true + exit 1 + fi + cp "$APK" /src/_src/PostIt.Android.apk + ls -la /src/_src/PostIt.Android.apk - name: Publier la release Forgejo via l'API REST # Pas d'action tierce (pas de Node dans l'image runner). @@ -295,22 +310,21 @@ jobs: # sinon curl l'interprète comme un second fichier d'input # (un fichier nommé '?name=PostIt.Android.apk') et l'API # Forgejo renvoie 400 "Missing 'name' parameter". - echo "::group::Upload PostIt APK assets" - for MARCH in arm64 x64; do - HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - -H "Accept: application/json" \ - --data-binary "@/src/_src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-$MARCH/fr.pschneider.postit-Signed.apk" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android-$MARCH.apk") - echo "POST asset -> HTTP $HTTP" - if [[ "$HTTP" != "201" ]]; then - echo "::error::Asset upload failed (HTTP $HTTP):" - cat /tmp/asset.json - exit 1 - fi - done + echo "::group::Upload APK asset" + HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -H "Accept: application/json" \ + --data-binary "@/src/_src/PostIt.Android.apk" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk") + echo "POST asset -> HTTP $HTTP" echo "::endgroup::" - echo "✅ Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" + if [[ "$HTTP" != "201" ]]; then + echo "::error::Asset upload failed (HTTP $HTTP):" + cat /tmp/asset.json + exit 1 + fi + + echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml new file mode 100644 index 00000000..d560b216 --- /dev/null +++ b/.github/workflows/docker-publish-android.yml @@ -0,0 +1,183 @@ +name: Build and Push Yavsc Apk + +on: + push: + branches: + - main + tags: + - '*' + workflow_dispatch: + inputs: + force_unstable: + description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.' + required: false + type: boolean + default: false + +# softprops/action-gh-release a besoin de contents: write +# pour publier une release + uploader un asset. +permissions: + contents: write + +jobs: + apk-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout du code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + # 1. Votre étape de build actuelle (on nomme l'image "postit-android") + # --target build-env : on ne veut que le stage de build (qui + # contient les artefacts .apk). Sans --target, Docker ciblerait + # le DERNIER stage du Dockerfile (blogs-runtime, qui est une + # image ASP.NET runtime sans aucun APK à extraire). + - name: Build de l'image Docker + run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . + # 2. EXTRACTION : Créer un conteneur éphémère pour copier l'APK vers l'hôte GitHub + - name: Extraire l'APK du conteneur Docker + run: | + docker create --name extractor postit-android + docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk ./PostIt.Android.apk + docker rm extractor + + - name: Téléverser l'APK en tant qu'Artéfact GitHub + uses: actions/upload-artifact@v7 + with: + name: application-apk-release + path: ./PostIt.Android.apk + retention-days: 7 + + # Job de validation : parse le tag, vérifie le format, applique la règle + # de parité du patch (pair=stable / impair=preview / suffixe=instable), + # et s'assure que CHANGELOG.md contient une section cohérente. + # Sans ce job, le job publish-release peut être bypassé (un attaquant + # qui contrôle un tag ne peut pas publier de release sans une section + # changelog cohérente). + validate-release: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Checkout du code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Valider le tag et la section CHANGELOG + env: + FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }} + run: | + TAG="${GITHUB_REF_NAME}" + + # Parse semver : MAJOR.MINOR.PATCH[-SUFFIX] + if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then + echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." + exit 1 + fi + + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + SUFFIX="${BASH_REMATCH[4]}" + + # Classification du canal par parité du patch. + # Patch pair + pas de suffixe -> stable. + # Patch impair + pas de suffixe -> preview. + # Suffixe présent -> instable. + if [[ -n "$SUFFIX" ]]; then + CHANNEL="unstable" + elif (( PATCH % 2 == 0 )); then + CHANNEL="stable" + else + CHANNEL="preview" + fi + + echo "Tag $TAG classifié comme channel=$CHANNEL" + + # Fail-fast sur instable sauf opt-in explicite via workflow_dispatch. + if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then + echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." + echo "Set force_unstable=true via workflow_dispatch to override." + exit 1 + fi + + # Lecture du CHANGELOG.md (doit exister à la racine du repo). + if [[ ! -f CHANGELOG.md ]]; then + echo "::error::CHANGELOG.md not found at repo root." + exit 1 + fi + + # Extraction de la section [TAG]. On cherche la première ligne + # commençant par '## [' qui contient '[TAG]' (entre '## [' et + # la prochaine ligne '## [' ou fin de fichier). awk en mode + # paragraphe suffit et reste POSIX. + BODY=$(awk -v tag="[$TAG]" ' + /^## \[/ { + if (in_section) exit + if (index($0, tag) > 0) in_section=1 + next + } + in_section { print } + ' CHANGELOG.md) + + if [[ -z "$BODY" ]]; then + echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md." + echo "Add a '## [$TAG] - $CHANNEL' section before tagging." + exit 1 + fi + + # Vérification cohérence du canal déclaré dans le titre de section. + # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". + HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md) + if [[ "$HEADER" != *" - $CHANNEL"* ]]; then + echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." + echo "Current section header: $HEADER" + exit 1 + fi + + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" + + # Exposition aux étapes suivantes via $GITHUB_ENV. + # heredoc <> "$GITHUB_ENV" + + publish-release: + # Déclenché uniquement par un push de tag. Le job apk-deploy produit + # l'artefact ; validate-release garantit la cohérence du tag et du + # changelog avant publication. + if: startsWith(github.ref, 'refs/tags/') + needs: [apk-deploy, validate-release] + runs-on: ubuntu-latest + steps: + - name: Récupérer l'APK depuis l'artefact + uses: actions/download-artifact@v7 + with: + name: application-apk-release + path: ./ + + - name: Publier la release GitHub et uploader l'APK + uses: softprops/action-gh-release@v2 + with: + # Le nom de fichier final dans la release. C'est ce qui + # apparaîtra dans l'asset et donc dans le permalink : + # https://github.com///releases/latest/download/PostIt.Android.apk + files: ./PostIt.Android.apk + # Le body est extrait de la section CHANGELOG.md correspondant + # au tag, exposée par validate-release via $GITHUB_ENV. + body: ${{ env.RELEASE_BODY }} + # stable -> false (marque comme Latest). + # preview / unstable -> true (visible mais pas Latest). + prerelease: ${{ env.IS_PRERELEASE }} diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index d8466bdb..6c2431ae 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -26,7 +26,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Test - run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" + run: dotnet test --no-build --verbosity normal # 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/.vscode/launch.json b/.vscode/launch.json index dc8d3c68..42be176a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,57 +1,59 @@ { - // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. - // Pointez pour afficher la description des attributs existants. - // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Android Debug", + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug - Android", "type": "mono", "preLaunchTask": "run-debug-android", "request": "attach", "address": "localhost", - "port": 55555 + "port": 10000 }, { - "name": "Android Attach - Debug", + "name": "Attach - Android", "type": "mono", "request": "attach", "address": "localhost", - "port": 55555 + "port": 10000 }, - { - "name": "API", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/Api/Api.csproj" - }, - { - "name": "Yavsc Org", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", - }, - { - "name": "Yavsc Blogs", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" - }, - { - "name": "PostIt Desktop", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", - }, - { - "name": "Test PostIt.Android launch (Xamarin.UITest)", - "type": "coreclr", - "request": "launch", - "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", - "args": [], - "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", - "console": "integratedTerminal", - "stopAtEntry": false - } - ] + { + "name": "API", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/Api/Api.csproj" + }, + { + "name": "Yavsc.Org", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + }, + { + "name": "Yavsc.Blogs", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" + }, + { + "name": "PostIt", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", + + }, + { + "name": "Test PostIt.Android launch (Xamarin.UITest)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", + "args": [ + ], + "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 83a17ae3..16bbe483 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,31 +2,27 @@ "dotnet-test-explorer.testProjectPath": "test/**/*Tests.csproj", "cSpell.words": [ - "appsettings", - "asciidoctor", - "ASPNETCORE", - "Avalonia", - "blogspot", - "Configurabilité", - "Cratie", - "DESTDIR", - "dotnet", - "DOTNET", - "ecdsa", - "envsubst", - "Forgejo", - "Hsts", - "Newtonsoft", - "Npgsql", - "Oidc", - "PKCE", - "postit", - "pschneider", - "SLNDIR", - "validable", - "www-data", - "yavsc", - "Yavsc" + "appsettings", + "asciidoctor", + "ASPNETCORE", + "Configurabilité", + "Cratie", + "DESTDIR", + "dotnet", + "DOTNET", + "ecdsa", + "envsubst", + "Hsts", + "Newtonsoft", + "Npgsql", + "PKCE", + "postit", + "pschneider", + "SLNDIR", + "validable", + "www-data", + "yavsc", + "Yavsc" ], "cSpell.reportUnknownWords": true, "cSpell.language": "fr,en", @@ -44,6 +40,5 @@ "copilotcli/gpt-5.3-codex" ] } - }, - "dotnet.defaultSolution": "yavsc.sln" + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index fd46437a..c900fa6a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,22 +1,5 @@ { "version": "2.0.0", - "isRoot": true, - "problemMatcher": [ - { - "owner": "dotnet", - "fileLocation": ["relative", "${workspaceFolder}"], - "source": "dotnet", - "pattern": { - "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "code": 5, - "message": 6 - } - } - ], "tasks": [ { "label": "run-debug-android", @@ -27,17 +10,19 @@ "env": { "DOTNET_HOST_PATH": "/usr/share/dotnet", "ANDROID_HOME": "/opt/android-sdk", - "JAVA_HOME": "/usr/lib/jvm/java-1.25.0-openjdk-amd64" + "JAVA_HOME": "/usr/lib/jvm/java-1.21.0-openjdk-amd64" } }, "args": [ - "run", + "build" + "-t:run", "-p:TargetFramework=net10.0-android", "-p:Configuration=Debug", "-p:AndroidAttachDebugger=true", - "-p:AndroidSdbHostPort=55555", - "-p:AndroidSdbTargetPort=55555" - ] + "-p:AndroidSdbHostPort=10000", + "-p:AndroidSdbTargetPort=10000" + ], + "problemMatcher": "$msCompile" }, { "label": "build", @@ -47,6 +32,7 @@ "group": "build", "isBuildCommand": true, "isTestCommand": false, + "problemMatcher": ["$msCompile"], "isBackground": true }, { @@ -76,6 +62,59 @@ "kind": "build" }, "isBackground": true + }, + { + "label": "test blogs", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["test"], + "runOptions": { + "instanceLimit": 1 + }, + "options": { + "cwd": "src/Yavsc.Blogs", + "env": { + "DOTNET_CLI_UI_LANGUAGE": "en-US", + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "group": { + "kind": "test" + }, + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": ["watch", "--project", + "src/Yavsc.Org/Yavsc.Org.csproj" + ], + "problemMatcher": "$msCompile", + "runOptions": { + + } + } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index ec42b81b..e2a446a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,68 +1,20 @@ # Changelog -## [1.0.8-rc9] - unstable +Toutes les modifications notables de PostIt et de la plateforme Yavsc +sont documentées dans ce fichier. -### Added +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), +et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -nothing +À noter : la **parité du numéro de patch** porte une signification de canal : -### Changed +- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** +- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** +- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** -masquage non-owner côté backend de l'ACL du billet - -### Fixed - -On a maintenant le comportement attendu bout en bout: - -ACL chargée depuis le BlogPostDto -noms de cercles affichés dans le dialogue ACL côté PostIt - -## [1.0.8-rc8] - unstable - -### Added - -nothing - -### Changed - -nothing - -### Fixed - -The PostIt publish toggle button - -## [1.0.8-rc7] - unstable - -### Added - -* [PostIt] The search pattern now persists - -### Changed - -* The blog spot path is now `/api/v1/blogspot` (yet in last release) - -### Fixed - -* [Yavsc.Org] (Ticket #45) La forme de l'email de l'utilisateur est maintenant validée avant l'envoi du formulaire d'enregistrement - -## [1.0.8-rc6] - unstable - -### Added - -* a code cleanup, -* a first Xamarin.UITest is successful, but disabled, because breaking the actual CI process, -* Android app starts, the login process succeeds - -### Changed - -L'identifiant de l'application client Android a changé, il passe en minuscules : -`fr.pschneider.postit` - -### Fixed - -a bug posting and retrieving ACL from the backend, -the ACL now comes along with the article, -[TODO][PostIt] keep ACL along with the article +Cette convention est partagée avec le dépôt +[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) +pour la production des paquets `.deb`. ## [1.0.8-rc1] - unstable @@ -217,10 +169,10 @@ the ACL now comes along with the article, migration, reverted in this release. The publish toggle covers the same user-visible switch without a schema change. -[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD -[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 -[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 -[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 +[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD +[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1 +[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 +[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 ## [1.0.6] - stable @@ -254,4 +206,4 @@ the ACL now comes along with the article, actual release id. Switched to `jq` for both body construction and field extraction. -[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 +[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd408c5c..e528b7a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ ## Premier build ```bash -git clone https://forgejo.pschneider.fr/notazof/yavsc.git +git clone https://github.com/pazof/yavsc.git cd yavsc dotnet restore dotnet build @@ -49,26 +49,6 @@ Les tests sont répartis en : item « Tests d'intégration smoke par BC ». - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. -## Le CHANGELOG.md - -Le `CHANGELOG.md` est un document de changement de version - -Toutes les modifications notables de PostIt et de la plateforme Yavsc -sont documentées dans ce fichier. - -Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), -et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -À noter : la **parité du numéro de patch** porte une signification de canal : - -- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable** -- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview** -- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** - -Cette convention est partagée avec le dépôt -[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) -pour la production des paquets `.deb`. - ## Navigation (PostIt) La navigation est centralisée dans diff --git a/Directory.Build.props b/Directory.Build.props index 83d21579..aec8c990 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,16 @@ Yavsc - NU1701, NU1901, NU1902, NU1507 + + true + NU1701, NU1901, NU1902 diff --git a/Directory.Packages.props b/Directory.Packages.props index 7505c851..84380e44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/Makefile b/Makefile index 08f0461d..d6d69196 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,13 @@ release: echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \ exit 1; \ fi + @CURRENT=$$(git branch --show-current); \ + if [ "$$CURRENT" != "main" ]; then \ + echo "Refus : la cible doit être lancée depuis main."; \ + echo " Branche courante : $$CURRENT"; \ + echo " Fais : git checkout main && git pull --ff-only origin main"; \ + exit 1; \ + fi @if [ -n "$$(git status --porcelain)" ]; then \ echo "Working tree sale, refus de créer une branche release."; \ git status --short; \ @@ -114,4 +121,168 @@ release: git push -u origin "$$BRANCH"; \ echo "==> Terminé. Branche $$BRANCH live sur origin." -.PHONY: test release +# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu. +# +# Usage typique : +# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe +# make qemu-install # (re)build l'APK et l'installe (AVD doit tourner) +# make qemu-build # build l'APK seul (sans install) +# make qemu-run # démarre l'AVD en background +# make qemu-stop # arrête l'émulateur +# make qemu-wait-boot # attend que l'AVD ait fini de booter +# +# Variables surchargeables (make VAR=valeur) : +# AVD_NAME default: postit_test_avd +# (l'AVD doit être listé par `avdmanager list avd`) +# ADB_SERIAL default: emulator-5554 +# (port standard du premier émulateur lancé) +# ANDROID_HOME default: /opt/android-sdk +# (le SDK Android local; doit contenir +# emulator/emulator et platform-tools/adb) +# POSTIT_RID default: android-x64 +# (doit matcher l'ABI de l'AVD; `avdmanager list avd` +# affiche la ligne Tag/ABI) +# EMU_HEADLESS default: 0 +# (1 = lancer l'émulateur sans fenêtre, pour scripter) +# CONFIG surcharge la variable CONFIG globale (Debug par +# défaut dans ce Makefile). Passer à Release pour +# un APK optimisé et signé release. +# LOGCAT_LINES default: 200 +# (nombre de lignes dumpées par `make qemu-logcat`) +# LOGCAT_FOLLOW default: 0 +# (1 = stream live via `make qemu-logcat`, +# sinon dump one-shot des N dernières lignes) +# LOGCAT_BOOT_WAIT default: 30 +# (secondes d'attente entre le clear du buffer, +# le `am start`, et le dump final dans +# `make qemu-logcat-boot`) +AVD_NAME ?= postit_test_avd +ADB_SERIAL ?= emulator-5554 +ANDROID_HOME ?= /opt/android-sdk +POSTIT_RID ?= android-x64 +EMU_HEADLESS ?= 0 +LOGCAT_LINES ?= 600 +LOGCAT_FOLLOW ?= 0 +LOGCAT_BOOT_WAIT ?= 20 + +ANDROID_PACKAGE_NAME = fr.pschneider.PostIt +POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj +POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) +POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk + +qemu-run: + @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." + @mkdir -p /tmp/yavsc-emu + @EMU_ARGS=""; \ + if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \ + $(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \ + >/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \ + echo " emulator PID: $$!" + +qemu-stop: + adb -s $(ADB_SERIAL) emu kill + +qemu-wait-boot: + @echo " Waiting for $(ADB_SERIAL) to finish booting..." + adb -s $(ADB_SERIAL) wait-for-device + @for i in $$(seq 1 180); do \ + BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \ + if [ "$$BOOTED" = "1" ]; then \ + echo " ✓ booted in $${i}s"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo " ERROR: device did not boot within 180s." >&2; \ + echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \ + exit 1 + +qemu-build: + # EmbedAssembliesIntoApk=true: without this, the Debug APK ships + # without the managed assemblies in it (they are pushed at runtime + # via `adb push`, "Fast Deployment"). On the qemu emulator, the + # runtime cannot find them in `files/.__override__//` and + # aborts at startup with "No assemblies found in '.__override__'" + # (monodroid-glue.cc:757, SIGABRT). Forcing this property on + # packages the .dlls into the APK as `assemblies//` so the + # runtime reads them directly. + # + # The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`, + # not `AndroidEnableFastDeployment` (which exists in older + # templates but is a no-op in the .NET 10 SDK). + dotnet build $(POSTIT_ANDROID_CSPROJ) \ + -c $(CONFIG) \ + -p:RuntimeIdentifier=$(POSTIT_RID) \ + -p:EmbedAssembliesIntoApk=true \ + --nologo + @if [ ! -f "$(POSTIT_APK)" ]; then \ + echo " APK not found at $(POSTIT_APK)." >&2; \ + echo " Files in $(POSTIT_APK_DIR):" >&2; \ + ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \ + exit 1; \ + fi + + +qemu-install: qemu-build + @echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..." + adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r + +qemu-uninstall: + adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) + +# Dump recent logcat output for the running PostIt.Android process. +# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with +# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. +# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag, +# because Mono/Xamarin can emit logs under several tags +# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering +# would miss the ones not matching. PID-based filtering is exact. +# If the app is not running, pidof returns empty and logcat exits +# silently with no output; that is the expected behaviour for +# "no logs yet". +qemu-logcat: + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ + if [ -z "$$PID" ]; then \ + echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ + echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ + exit 1; \ + fi; \ + echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ + if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \ + else \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ + fi + +# Clear logcat, launch PostIt.Android, then dump everything that was +# emitted during the startup window. Targets the "démarrage KO" case +# where the process starts but Avalonia never renders a frame — the +# logcat trace from process start to first frame is what diagnoses it. +# +# Override LOGCAT_BOOT_WAIT to extend the post-launch wait +# (default 15s; raise to 30+ if the device is slow to boot Avalonia). +LOGCAT_BOOT_WAIT ?= 15 +qemu-logcat-boot: + @echo " Clearing logcat buffer..." + adb -s $(ADB_SERIAL) logcat -c + @echo " Launching $(ANDROID_PACKAGE_NAME)..." + adb -s $(ADB_SERIAL) shell am start \ + -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity + @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." + @sleep $(LOGCAT_BOOT_WAIT) + + @echo " Dumping logcat (PostIt PID + system buffer):" + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ + if [ -n "$$PID" ]; then \ + echo " ✅ (PID $$PID at dump time)"; \ + adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ + else \ + echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ + exit 1; \ + fi + +qemu: qemu-run qemu-wait-boot qemu-install + @echo " ✓ PostIt.Android installed on $(ADB_SERIAL)" + +.PHONY: test release qemu qemu-run qemu-stop qemu-wait-boot qemu-build qemu-install qemu-logcat qemu-logcat-boot diff --git a/README.md b/README.md index b132f3f2..8e630612 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml # Statut actuel des actions GitHub -* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml) +* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) * [![Build and Push Yavsc Production Image](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml) +* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml) # Documentation diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 4dd4b288..69e8a896 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -5,29 +5,33 @@ true - 12.1.1 - - - - - - - - - + + + + + + + + + + + + - - - + + + - + + + diff --git a/src/PostIt/Makefile b/src/PostIt/Makefile deleted file mode 100644 index 1217976b..00000000 --- a/src/PostIt/Makefile +++ /dev/null @@ -1,178 +0,0 @@ - -# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu. -# -# Usage typique : -# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe -# make android-install # (re)build l'APK et l'installe (AVD doit tourner) -# make android-build # build l'APK seul (sans install) -# make qemu-run # démarre l'AVD en background -# make qemu-stop # arrête l'émulateur -# make qemu-wait-boot # attend que l'AVD ait fini de booter -# -# Variables surchargeables (make VAR=valeur) : -# AVD_NAME default: postit_test_avd -# (l'AVD doit être listé par `avdmanager list avd`) -# ADB_SERIAL default: emulator-5554 -# (port standard du premier émulateur lancé) -# ANDROID_HOME default: /opt/android-sdk -# (le SDK Android local; doit contenir -# emulator/emulator et platform-tools/adb) -# POSTIT_RID default: android-x64 -# (doit matcher l'ABI de l'AVD; `avdmanager list avd` -# affiche la ligne Tag/ABI) -# EMU_HEADLESS default: 0 -# (1 = lancer l'émulateur sans fenêtre, pour scripter) -# CONFIG surcharge la variable CONFIG globale (Debug par -# défaut dans ce Makefile). Passer à Release pour -# un APK optimisé et signé release. -# LOGCAT_LINES default: 200 -# (nombre de lignes dumpées par `make qemu-logcat`) -# LOGCAT_FOLLOW default: 0 -# (1 = stream live via `make logcat`, -# sinon dump one-shot des N dernières lignes) -# LOGCAT_BOOT_WAIT default: 30 -# (secondes d'attente entre le clear du buffer, -# le `am start`, et le dump final dans -# `make qemu-logcat-boot`) -AVD_NAME ?= postit_test_avd -ADB_SERIAL ?= emulator-5554 -ANDROID_HOME ?= /opt/android-sdk -POSTIT_RID ?= android-x64 -EMU_HEADLESS ?= 0 -LOGCAT_LINES ?= 600 -LOGCAT_FOLLOW ?= 0 -LOGCAT_BOOT_WAIT ?= 30 - -ANDROID_PACKAGE_NAME = fr.pschneider.postit -POSTIT_ANDROID_CSPROJ := PostIt.Android/PostIt.Android.csproj -POSTIT_APK_DIR := PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) -POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk - -clean: clean-PostIt clean-PostIt.Android clean-PostIt.Desktop - -clean-%: - rm -rf $*/obj $*/bin - -qemu-run: - @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." - @mkdir -p /tmp/yavsc-emu - @EMU_ARGS=""; \ - if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \ - $(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \ - >/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \ - echo " ✅ Started emulator PID: $$!" - -qemu-stop: - adb -s $(ADB_SERIAL) emu kill - echo " ✅ Stopped emulator" - -qemu-wait-boot: - @echo " Waiting for $(ADB_SERIAL) to finish booting..." - adb -s $(ADB_SERIAL) wait-for-device - @for i in $$(seq 1 180); do \ - BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \ - if [ "$$BOOTED" = "1" ]; then \ - echo " ✓ booted in $${i}s"; \ - exit 0; \ - fi; \ - sleep 1; \ - done; \ - echo " 👿 ERROR: device did not boot within 180s." >&2; \ - echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \ - exit 1 - -android-build: - # EmbedAssembliesIntoApk=true: without this, the Debug APK ships - # without the managed assemblies in it (they are pushed at runtime - # via `adb push`, "Fast Deployment"). On the qemu emulator, the - # runtime cannot find them in `files/.__override__//` and - # aborts at startup with "No assemblies found in '.__override__'" - # (monodroid-glue.cc:757, SIGABRT). Forcing this property on - # packages the .dlls into the APK as `assemblies//` so the - # runtime reads them directly. - # - # The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`, - # not `AndroidEnableFastDeployment` (which exists in older - # templates but is a no-op in the .NET 10 SDK). - dotnet build $(POSTIT_ANDROID_CSPROJ) \ - -c $(CONFIG) \ - -p:RuntimeIdentifier=$(POSTIT_RID) \ - -p:EmbedAssembliesIntoApk=true \ - --nologo - @if [ ! -f "$(POSTIT_APK)" ]; then \ - echo " APK not found at $(POSTIT_APK)." >&2; \ - echo " Files in $(POSTIT_APK_DIR):" >&2; \ - ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \ - exit 1; \ - fi - - -android-install: android-build - @echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..." - adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r - @echo " ✅ PostIt.Android installed on $(ADB_SERIAL)" - -qemu-uninstall: - adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) - -# Dump recent logcat output for the running PostIt.Android process. -# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with -# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. -# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag, -# because Mono/Xamarin can emit logs under several tags -# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering -# would miss the ones not matching. PID-based filtering is exact. -# If the app is not running, pidof returns empty and logcat exits -# silently with no output; that is the expected behaviour for -# "no logs yet". -logcat: - @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ - if [ -z "$$PID" ]; then \ - echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ - echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ - exit 1; \ - fi; \ - echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ - if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ - adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \ - else \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ - fi - -# Clear logcat, launch PostIt.Android, then dump everything that was -# emitted during the startup window. Targets the "démarrage KO" case -# where the process starts but Avalonia never renders a frame — the -# logcat trace from process start to first frame is what diagnoses it. -# -# Override LOGCAT_BOOT_WAIT to extend the post-launch wait -# (default 15s; raise to 30+ if the device is slow to boot Avalonia). -LOGCAT_BOOT_WAIT ?= 15 - - -android-start: - @echo " Clearing logcat buffer..." - adb -s $(ADB_SERIAL) logcat -c - @echo " Launching $(ANDROID_PACKAGE_NAME)..." - adb -s $(ADB_SERIAL) shell am start \ - -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity - @echo " ✅ $(ANDROID_PACKAGE_NAME) started on $(ADB_SERIAL)" - -qemu-logcat-boot: android-start - @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." - @sleep $(LOGCAT_BOOT_WAIT) - - @echo " Dumping logcat (PostIt PID + system buffer):" - @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ - if [ -n "$$PID" ]; then \ - echo " ✅ (PID $$PID at dump time)"; \ - sleep 10; \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID; \ - else \ - echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ - exit 1; \ - fi - -qemu: qemu-run qemu-wait-boot android-install - -.PHONY: clean qemu qemu-run qemu-stop qemu-wait-boot android-build android-install logcat qemu-logcat-boot diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index ad8455ef..62c29e10 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -1,11 +1,8 @@ - -using Android.App; +using Android.App; using Android.Content; using Android.Content.PM; -using AndroidX.Core.Provider; -using AndroidX.Emoji2.Text; +using Avalonia; using Avalonia.Android; -using PostIt.Droid.Services; namespace PostIt.Android; @@ -18,22 +15,18 @@ namespace PostIt.Android; ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { - /// - /// The current MainActivity instance. + /// + /// Strongly-typed handle to the current MainActivity instance, set in + /// and consumed by platform services such as + /// which need to launch + /// Chrome Custom Tabs. /// public static MainActivity? Current { get; private set; } protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState) { - FontRequest fontRequest = new FontRequest( - "com.google.android.gms.fonts", - "com.google.android.gms", - "Noto Color Emoji Compat", - Yavsc.Resource.Array.com_google_android_gms_fonts_certs); //com_google_android_gms_fonts_certs - EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest); - EmojiCompat.Init(config); - PlatformBootstrap.InitPlatform(); base.OnCreate(savedInstanceState); + PlatformBootstrap.EnsureInitialized(); Current = this; } /// @@ -48,13 +41,7 @@ public class MainActivity : AvaloniaMainActivity protected override void OnNewIntent(Intent? intent) { base.OnNewIntent(intent); - - var url = intent?.DataString; - if (!string.IsNullOrEmpty(url) && url.StartsWith("postit://callback")) - { - OidcCallbackManager.SetResult(url); - } - + if (intent is not null) AndroidOidcCallbackSink.Handle(intent); } internal static class AndroidOidcCallbackSink diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index f208f9ce..d59b154f 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -12,9 +12,14 @@ namespace PostIt.Android; /// internal static class PlatformBootstrap { - internal static void InitPlatform() - { + private static int _initialized; + internal static void EnsureInitialized() + { + if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) + return; + + Platform.DefaultRedirectUri = ViewModels.Settings.AndroidRedirectUri; Platform.CreateBrowser = () => { var activity = MainActivity.Current; diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 3d8be385..a4af1eb2 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -4,27 +4,30 @@ net10.0-android 23 enable - fr.pschneider.postit + fr.pschneider.PostIt 1 1.0 apk - false - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + false + SdkOnly + partial + Resources\drawable\Icon.png + + + + - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 8793aae8..91b61d05 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -2,5 +2,31 @@ + + + + + + + + + + diff --git a/src/PostIt/PostIt.Android/Resources/values/font_certs.xml b/src/PostIt/PostIt.Android/Resources/values/font_certs.xml deleted file mode 100644 index f4adce1b..00000000 --- a/src/PostIt/PostIt.Android/Resources/values/font_certs.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - @array/com_google_android_gms_fonts_certs_dev - @array/com_google_android_gms_fonts_certs_prod - - - MIIEqDCCA5CgAwIBAgIJAN5gc16AJfAsMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRAwDgYDVQQDEwdBbmRyb2lkMSEwHwYJKoZIhvcNAQkBFhJhbmRyb2lkQGFuZHJvaWQuY29tMCAXDTA4MDQxNTIyNDA0M1YYDzQyMDgxMzA0MjI0MDQzWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDURvdW50YWluIFZpZXcxEDAOBgNVBAoTB0dvb2dsZTEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEhMB8GCSqGSIb3DQEJARYSYW5kcm9pZEBhbmRyb2lkLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALBi1vF0K1vOEHG7AxneTjOHUka46MIidBqvFcO164A49iU2DkYPhUaM4H8JCdzh6N1GzM6h9o6E2V6z8+gEtdI6nqqs0EGA0G0H701bFjLp9+K/1DkMIFeD4P8J7X1/M8t4+X09X/7bQyV3w0v7q+Qh38sY8W/7K29B3f2O2sLw+uX9U8a8Tf4Xv8A== - - - MIIEQzCCAyugAwIBAgIJAMLgh0ZgXpYOMA0GCSqGSIb3DQEBBQUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKKvSkUIXm+t9M8rXj2V - - diff --git a/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs b/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs index bb10b364..b6716e22 100644 --- a/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs +++ b/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Android.App; using AndroidX.Browser.CustomTabs; using IdentityModel.OidcClient.Browser; -using PostIt.Droid.Services; namespace PostIt.Android.Services; @@ -36,15 +35,10 @@ public sealed class AndroidSystemBrowser : IBrowser }; } - // 1. Enregistrez la tâche avant de lancer le Custom Tab - var callbackTask = OidcCallbackManager.RegisterCallback(cancellationToken); - - // 2. LANCEZ VOTRE CUSTOM TAB ICI (via AndroidX.Browser.CustomTabs) - // ... code pour ouvrir l'URL d'authentification ... - - var uri = global::Android.Net.Uri.Parse(options.StartUrl)!; + var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync(); + var tabsIntent = new CustomTabsIntent.Builder() .SetShowTitle(true)! .Build(); diff --git a/src/PostIt/PostIt.Android/Services/OidcCallbackManager.cs b/src/PostIt/PostIt.Android/Services/OidcCallbackManager.cs deleted file mode 100644 index 30f8fa18..00000000 --- a/src/PostIt/PostIt.Android/Services/OidcCallbackManager.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace PostIt.Droid.Services; - -public static class OidcCallbackManager -{ - private static TaskCompletionSource? _tcs; - - public static Task RegisterCallback(CancellationToken cancellationToken) - { - _tcs = new TaskCompletionSource(); - cancellationToken.Register(() => _tcs.TrySetCanceled()); - return _tcs.Task; - } - - public static void SetResult(string url) - { - _tcs?.TrySetResult(url); - } -} diff --git a/src/PostIt/PostIt.Android/WebAuthenticationCallbackActivity.cs b/src/PostIt/PostIt.Android/WebAuthenticationCallbackActivity.cs deleted file mode 100644 index 9ed2eb18..00000000 --- a/src/PostIt/PostIt.Android/WebAuthenticationCallbackActivity.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Android.App; -using Android.Content; -using Android.Content.PM; -using Android.OS; -using PostIt.Droid.Services; - -namespace PostIt.Android; - -[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)] -[IntentFilter(new[] { Intent.ActionView }, - Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, - DataScheme = "postit", // Remplacez par votre schéma personnalisé (ex: yavsc ou postit) - DataHost = "callback")] // Correspond à postit://callback -public class WebAuthenticationCallbackActivity : Activity -{ - protected override void OnCreate(Bundle? savedInstanceState) - { - base.OnCreate(savedInstanceState); - - // Capturer l'URL de redirection OIDC - var url = Intent?.DataString; - - if (!string.IsNullOrEmpty(url)) - { - // Transmettre l'URL au gestionnaire partagé pour compléter la Task - OidcCallbackManager.SetResult(url); - } - - // Fermer cette activité transparente et ramener l'application au premier plan - var intent = new Intent(this, typeof(MainActivity)); - intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop); - StartActivity(intent); - Finish(); - } -} diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 4534a294..5202f397 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,7 +6,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -15,4 +15,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs new file mode 100644 index 00000000..ff9ca7f6 --- /dev/null +++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs @@ -0,0 +1,29 @@ +using PostIt.Services; + +namespace PostIt.Desktop; + +/// +/// One-shot platform bootstrap. Called from Program.Main so that +/// the shared OIDC login path sees a working IBrowser — the +/// custom-scheme browser that hands the OIDC callback off to the +/// running instance through the named pipe. Desktop builds do NOT use +/// a loopback HTTP listener: the postit:// scheme is registered +/// with the OS at install time and the browser is whatever the user +/// has configured to open it. +/// +internal static class PlatformBootstrap +{ + private static int _initialized; + + internal static void EnsureInitialized() + { + if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) + return; + + // Use the custom-scheme redirect on Desktop. Loopback is only + // a fallback for platforms that cannot register postit:// + // (see Settings.DefaultLoopbackRedirectUri for that path). + Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri; + Platform.CustomScheme = "postit"; + } +} diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 948e726c..4048c45e 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,7 +7,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -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 0de3bd69..f22f79b0 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -12,6 +12,8 @@ sealed class Program [STAThread] public static void Main(string[] args) { + PlatformBootstrap.EnsureInitialized(); + // Short-circuit 2nd-instance launches (OS handing us the // postit://callback URL) BEFORE Avalonia spins up a window. // If we let Avalonia initialise, the new MainWindow flashes diff --git a/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs b/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs index 8bec1dc6..e2426457 100644 --- a/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs +++ b/src/PostIt/PostIt.Tests/AddCircleMemberDialogTests.cs @@ -73,7 +73,7 @@ public class AddCircleMemberDialogTests return context; } /// - /// Mount a real , build a minimal + /// Mount a real , build a minimal /// DI graph, push then the /// on top of it. /// Returns the stack size so the test can pin the delta. @@ -98,9 +98,12 @@ public class AddCircleMemberDialogTests services.AddTransient(); var sp = services.BuildServiceProvider(); - context.Window = new MainView(); + context.Window = new MainWindow(); context.App = (PostIt.App)Application.Current!; + context.App.DataTemplates.Clear(); + context.App.DataTemplates.Add(new ViewLocator(sp)); context.App.AttachMainWindow(context.Window); + context.Window.Show(); context.page = sp.GetRequiredService(); context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult(); diff --git a/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs b/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs index 25630c83..ac4756eb 100644 --- a/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs +++ b/src/PostIt/PostIt.Tests/AndroidAppLaunchTests.cs @@ -12,10 +12,9 @@ namespace PostIt.Tests; /// Skip conditions: the package is not installed on the connected device, /// or no device is connected via adb. /// -[Trait("Category", "Platform-Android")] public class AndroidAppLaunchTests { - private const string PackageName = "fr.pschneider.postit"; + private const string PackageName = "fr.pschneider.PostIt"; private readonly ITestOutputHelper _output; @@ -24,8 +23,7 @@ public class AndroidAppLaunchTests _output = output; } - // TODO https://twosixtech.com/blog/integrating-docker-and-adb/ - [Fact] + // FIXME [Fact] public void PostIt_starts_and_draws_a_first_frame_on_the_emulator() { if (!IsPackageInstalledOnAnyDevice()) diff --git a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs index daabdf59..895f220e 100644 --- a/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs +++ b/src/PostIt/PostIt.Tests/BlogPostAuthorDtoTests.cs @@ -166,51 +166,4 @@ public class BlogPostAuthorDtoTests Assert.True(root.TryGetProperty("userName", out _)); Assert.True(root.TryGetProperty("avatar", out _)); } - - [Fact] - public void BlogPostDto_deserialises_acl_from_detail_payload() - { - // Detail payload shape emitted by BlogApiController.GetBlog: - // ACL entries are included under "acl"/"ACL". - var json = """ - { - "id": 99, - "title": "ACL test", - "authorId": "u-alice", - "acl": [ - { "circleId": 12, "blogPostId": 99 }, - { "circleId": 34, "blogPostId": 99 } - ] - } - """; - - var post = JsonSerializer.Deserialize(json, CaseInsensitiveJson); - - Assert.NotNull(post); - var acl = post!.GetACL(); - Assert.Equal(2, acl.Length); - Assert.Contains(acl, a => a.CircleId == 12); - Assert.Contains(acl, a => a.CircleId == 34); - } - - [Fact] - public void BlogPostDto_does_not_emit_acl_when_serialized_for_write() - { - var post = new BlogPostDto - { - Id = 77, - Title = "Write payload" - }; - post.AuthorizeCircle(11); - - // The client should not send ACL through POST/PUT blog payloads. - // ACL mutations have their own dedicated /blogacl endpoint. - var json = JsonSerializer.Serialize(post, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - Assert.False(root.TryGetProperty("acl", out _)); - Assert.False(root.TryGetProperty("wireAcl", out _)); - } } diff --git a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs index d1d00532..3e7344a2 100644 --- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs @@ -99,7 +99,7 @@ public class MainPageButtonsTests } /// - /// Mount a real (as + /// Mount a real (as /// SessionStatusBannerTests does), push a /// with the given VM onto /// NavRoot. PushAsync is awaited (via @@ -109,12 +109,18 @@ public class MainPageButtonsTests /// realised and KeyPressQwerty has a real /// to dispatch against. /// - private static (MainView window, MainPage page) MountMainPage(MainViewModel vm) + private static (MainWindow window, MainPage page) MountMainPage(MainViewModel vm) { - var window = new MainView(); + var window = new MainWindow(); var page = new MainPage { DataContext = vm }; var app = (PostIt.App)Application.Current!; + if (vm.Services is not null) + { + app.DataTemplates.Clear(); + app.DataTemplates.Add(new ViewLocator(vm.Services)); + } app.AttachMainWindow(window); + window.Show(); window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); return (window, page); } @@ -124,7 +130,7 @@ public class MainPageButtonsTests /// supported headless pattern (cf. CalculatorTests in the /// Avalonia.Samples repo). Returns the nav-stack count /// before the click so the caller can assert on the delta. - /// KeyPressQwerty is dispatched on the + /// KeyPressQwerty is dispatched on the /// itself — it is the that owns the /// headless implementation, and routing the key through any /// descendant TopLevel (e.g. one obtained via @@ -133,7 +139,7 @@ public class MainPageButtonsTests /// because the descendant does not carry the /// PlatformHandle the harness expects. /// - private static int ClickAndCapture(MainView window, Button button) + private static int ClickAndCapture(MainWindow window, Button button) { var stackBefore = window.NavRoot.NavigationStack.Count; button.Command?.Execute(button.CommandParameter); diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 0fd5627c..5baae658 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -79,9 +79,9 @@ public class MainPageSaveTests // whose Title is exactly what the user typed. The bug // fails this assertion with Title == string.Empty. Assert.NotEmpty(recorder.Calls); - var (method, path, body) = recorder.Calls[1]; + var (method, path, body) = recorder.FirstCall; Assert.Equal(HttpMethod.Post, method); - Assert.Equal("blogspot", path); + Assert.Equal("blog", path); var sent = Assert.IsType(body); Assert.Equal(typed, sent.Title); } diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index dd277629..9bc28888 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -9,7 +9,6 @@ using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; using Yavsc.Api.Client; -using Yavsc.Api.Client.Dtos; using Yavsc.Blogspot; namespace PostIt.Tests; @@ -118,7 +117,7 @@ public class PostAclDialogTests /// rebinding the global DI mid-test would trample the /// Settings singleton the rest of the harness depends on. /// - private static (MainView window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount() + private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount() { var handler = new CountingHttpHandler(); var settings = new Settings(); @@ -139,9 +138,12 @@ public class PostAclDialogTests // CountingHttpHandler. GC.KeepAlive(sp); - var window = new MainView(); + var window = new MainWindow(); var app = (App)Application.Current!; + app.DataTemplates.Clear(); + app.DataTemplates.Add(new ViewLocator(sp)); app.AttachMainWindow(window); + window.Show(); return (window, aclClient, circleClient, handler); } @@ -191,8 +193,9 @@ public class PostAclDialogTests await Task.Delay(20); } - // Assert: one GET went out (for /circle) from LoadAsync. - Assert.Equal(1, handler.RequestCount); + // Assert: exactly two GETs went out (one to /blogacl, + // one to /circle), both from the LoadAsync call. + Assert.Equal(2, handler.RequestCount); // And the VM's idempotency gate has flipped. Assert.True(vm.Loaded); @@ -218,58 +221,7 @@ public class PostAclDialogTests await vm.LoadAsync(); // Assert: the second call short-circuited on _loaded. - Assert.Equal(1, handler.RequestCount); + Assert.Equal(2, handler.RequestCount); Assert.True(vm.Loaded); } - - [Fact] - public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles() - { - var post = new BlogPostDto { Id = 42, Title = "ACL hydration" }; - post.AuthorizeCircle(12); - post.AuthorizeCircle(34); - - var api = new StubAclApiClient(); - var aclClient = new BlogAclApiClient(api, "http://localhost/"); - var circleClient = new CircleApiClient(api, "http://localhost/"); - var vm = new PostAclDialogViewModel(post, aclClient, circleClient); - - await vm.LoadAsync(); - - Assert.Equal(1, api.CallCount); - Assert.Equal(2, vm.AclEntries.Count); - Assert.Contains(vm.AclEntries, a => a.CircleId == 12); - Assert.Contains(vm.AclEntries, a => a.CircleId == 34); - } - - private sealed class StubAclApiClient : IYavscApiClient - { - public HttpClient Http { get; } = new(); - public int CallCount { get; private set; } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - CallCount++; - - if (typeof(T) == typeof(List)) - { - var circles = new List - { - new() { Id = 12, Name = "A", OwnerId = "owner", Public = false }, - new() { Id = 34, Name = "B", OwnerId = "owner", Public = false }, - }; - return Task.FromResult((T)(object)circles); - } - - return Task.FromResult(default(T)!); - } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - CallCount++; - return Task.CompletedTask; - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } } diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index 2c4f954d..433f36c3 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -16,7 +16,9 @@ + + @@ -26,5 +28,6 @@ - - \ No newline at end of file + + + diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 1a867bd6..d2c5d78e 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -55,21 +55,6 @@ public class PostItViewModelTests Assert.Equal("Hello", posts[0].Title); } - [Fact] - public async Task TogglePublishCommand_uses_the_current_checked_state_without_inverting_it() - { - var api = new RecordingPublishApi(); - var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainViewModel(blog); - - viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; - - await viewModel.SetPublishStateAsync(true); - - Assert.True(api.LastPublishValue); - Assert.True(viewModel.DraftIsPublished); - } - /// Test fake that always throws if the API is invoked. private sealed class ThrowingYavscApiClient : YavscApiClient { @@ -118,34 +103,4 @@ public class PostItViewModelTests return Task.FromResult(default(T)!); } } - - private sealed class RecordingPublishApi : IYavscApiClient - { - public bool LastPublishValue { get; private set; } - public HttpClient Http { get; } = new(); - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase)) - { - var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value; - LastPublishValue = publish; - } - - return Task.FromResult(default(T)!); - } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase)) - { - var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value; - LastPublishValue = publish; - } - - return Task.CompletedTask; - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } } diff --git a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs index 4d49b865..e1a5dd19 100644 --- a/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs +++ b/src/PostIt/PostIt.Tests/SessionStatusBannerTests.cs @@ -9,7 +9,7 @@ namespace PostIt.Tests; /// /// UI tests for . Mounted inside -/// a real via the headless Avalonia +/// a real via the headless Avalonia /// platform declared in TestApp.cs. /// /// The pattern is the one that UnitTest1.MainPage_Should_Load @@ -34,10 +34,11 @@ public class SessionStatusBannerTests [AvaloniaFact] public void Banner_renders_three_buttons_in_the_visual_tree() { - MainWindow window = new MainWindow(); + var window = new MainWindow(); + window.SessionBanner.DataContext = new SessionStatusViewModel(); window.Show(); - var buttons = window.GetVisualDescendants() + var buttons = window.SessionBanner.GetVisualDescendants() .OfType - public const string RedirectUri = "postit://callback"; + public static string DefaultRedirectUri { get; set; } = "postit://callback"; /// /// Scheme prefix the matches /// against BrowserOptions.EndUrl. Overridable for apps /// that want to register their own scheme. /// - public const string CustomScheme = "postit"; + public static string CustomScheme { get; set; } = "postit"; /// /// Constructs a fresh for the running platform. @@ -37,4 +37,4 @@ public static class Platform /// public static System.Func? CreateBrowser { get; set; } = () => new CustomSchemeBrowser(CustomScheme); -} +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/UiDispatcher.cs b/src/PostIt/PostIt/Services/UiDispatcher.cs new file mode 100644 index 00000000..e935ac1a --- /dev/null +++ b/src/PostIt/PostIt/Services/UiDispatcher.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace PostIt.Services; + +/// +/// Tiny marshalling helper around so +/// the rest of the codebase does not have to import Avalonia.Threading +/// directly. We want exactly one place that decides "is the current +/// thread the Avalonia UI thread, and if not, post there" so that +/// -derived types (Settings, the various +/// ViewModels) can fire PropertyChanged safely from background +/// work — which is exactly the cross-thread case that previously blew +/// up inside DataValidationErrors.SetErrors on Avalonia 11. +/// +/// The helper is intentionally tiny: a sync post when we are off the +/// UI thread, a no-op when we are already on it, and an async fire- +/// and-forget variant for places where awaiting would deadlock the +/// caller (e.g. Settings.Load continuation paths). +/// +public static class UiDispatcher +{ + /// + /// True when the calling thread is the Avalonia UI thread. Property + /// setters that touch bindings should check this before mutating + /// state; the safe path is . + /// + public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess(); + + /// + /// Run on the UI thread. If the caller is + /// already on the UI thread, run synchronously to preserve stack + /// traces and ordering; otherwise post to the dispatcher and wait. + /// Never throws on shutdown — a missing dispatcher is treated as + /// "best-effort skipped", matching Avalonia's own behaviour when + /// the application lifetime has been torn down. + /// + public static void InvokeIfNeeded(Action action) + { + if (action is null) return; + if (IsOnUiThread) { action(); return; } + try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); } + catch (InvalidOperationException) { /* dispatcher gone, nothing to do */ } + } + + /// + /// Fire-and-forget variant: schedules on + /// the UI thread but does not block the caller. Use this from + /// background workers (OIDC discovery, HTTP callbacks, file I/O) + /// where awaiting the dispatcher would deadlock the calling sync + /// context. + /// + public static void Post(Action action) + { + if (action is null) return; + try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); } + catch (InvalidOperationException) { /* dispatcher gone */ } + } + + /// + /// Awaitable variant. Useful inside async ViewModel methods + /// that must touch bindings only after the dispatcher has processed + /// a queued update (e.g. "load file then refresh observable state"). + /// + public static Task InvokeAsync(Action action) + { + if (action is null) return Task.CompletedTask; + if (IsOnUiThread) { action(); return Task.CompletedTask; } + return Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Normal).GetTask(); + } +} diff --git a/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs similarity index 90% rename from src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs rename to src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 8034820d..99358cfb 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -10,7 +10,7 @@ public partial class AuthenticationSettings : ObservableObject /// hand-off in /// (RFC 8252 §7.1). Production Desktop builds use this. /// - public const string DesktopRedirectUri = "postit://callback"; + public const string DefaultDesktopRedirectUri = "postit://callback"; /// /// Redirect URI used by the Android app. The corresponding IntentFilter @@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject /// public const string AndroidRedirectUri = "android://postit-signin"; - public const string DefaultAuthority = "https://yavsc.pschneider.fr"; + public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr"; - public const string DefaultClientId = "postit"; + public static string DefaultClientId { get; internal set; } = "postit"; - public static readonly string[] DefaultScopes = { "blogs" }; + public static string[] DefaultScopes { get; set; } = { "blogs"} ; [ObservableProperty] public partial string Authority { get; set; } @@ -34,19 +34,15 @@ public partial class AuthenticationSettings : ObservableObject [ObservableProperty] public partial string[] Scopes { get; set; } + /// - /// OAuth redirect URI. Defaults to + /// OAuth redirect URI. Defaults to /// (custom URI scheme) which is the right answer for desktop /// production builds. Mobile platforms must set this to /// before calling LoginAsync. /// [ObservableProperty] - public partial string RedirectUri { get; set; } -#if ANDROID - = AndroidRedirectUri; -#else - = DesktopRedirectUri; -#endif + public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; /// /// Space-separated view of . Exists for the diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 3543a9c9..03ab98a1 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -16,6 +16,12 @@ namespace PostIt; Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] public class ViewLocator : IDataTemplate { + private readonly IServiceProvider _services; + + public ViewLocator(IServiceProvider services) + { + _services = services; + } public Control Build(object? data) { @@ -32,17 +38,15 @@ public class ViewLocator : IDataTemplate private Control BuildCore(object? data) { - var app = App.Current as App; - var services = app!.ServiceProvider!; return data switch { - MainViewModel => services.GetRequiredService(), - Settings => services.GetRequiredService(), - HomePageViewModel => services.GetRequiredService(), - SignaturePageViewModel => services.GetRequiredService(), - AddCircleMemberDialogViewModel => services.GetRequiredService(), - CirclesPageViewModel => services.GetRequiredService(), - PostAclDialogViewModel => services.GetRequiredService(), + MainViewModel => _services.GetRequiredService(), + Settings => _services.GetRequiredService(), + HomePageViewModel => _services.GetRequiredService(), + SignaturePageViewModel => _services.GetRequiredService(), + AddCircleMemberDialogViewModel => _services.GetRequiredService(), + CirclesPageViewModel => _services.GetRequiredService(), + PostAclDialogViewModel => _services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index f56b666d..1aa3eee0 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase /// mutable field. Toggling is its own action. [ObservableProperty] public partial bool DraftIsPublished { get; set; } - public bool IsLoaded { get; private set; } + public Settings SettingsModel { get; } [ObservableProperty] @@ -72,218 +72,6 @@ public partial class MainViewModel : ViewModelBase [ObservableProperty] public partial Settings Settings { get; private set; } - [RelayCommand] - internal async Task RefreshAsync() - { - await ExecuteAsync(async () => - { - var posts = await BlogClient!.GetPostsAsync(); - Posts.Clear(); - foreach (var post in posts.OrderByDescending(p => p.DateModified)) - { - Posts.Add(post); - } - ApplyFilter(); - StatusMessage = $"Loaded {Posts.Count} posts."; - }); - } - - [RelayCommand] - internal async Task SearchAsync() { - await RefreshAsync(); - ApplyFilter(); - } - - [RelayCommand] - internal async Task SaveAsync() - { - // The button is already disabled when the title is empty - // (see CanSave), but the test path (and any programmatic - // ICommand.Execute) bypasses CanExecute, so we still - // guard here. Better to no-op with a status message - // than to send a request the server will reject. - if (string.IsNullOrWhiteSpace(DraftTitle)) - { - StatusMessage = "Title is required."; - return; - } - - await ExecuteAsync(async () => - { - // Build a fresh BlogPostDto from the editor buffer on - // every Save — we no longer mutate SelectedPost in - // place. The previous behaviour copied the buffer - // (which was a no-op when SelectedPost was null) - // back onto the model and relied on a - // [Required] violation to surface the missing - // input; the new shape keeps the editor buffer as - // the single source of truth for outgoing payloads - // and the selected post as a read-only hint for - // the update path. - if (SelectedPost is null || SelectedPost.Id == 0) - { - var draft = new BlogPostDto - { - Title = DraftTitle, - Article = DraftArticle ?? string.Empty, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - IsPublished = DraftIsPublished - }; - var created = await BlogClient!.CreatePostAsync(draft); - if (created is not null) - { - SelectedPost = created; - StatusMessage = $"Created post {created.Id}."; - } - } - else - { - var update = new BlogPostDto - { - Id = SelectedPost.Id, - AuthorId = SelectedPost.AuthorId, - Photo = SelectedPost.Photo, - Title = DraftTitle, - Article = DraftArticle ?? string.Empty, - DateCreated = SelectedPost.DateCreated, - DateModified = DateTime.UtcNow, - }; - await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); - StatusMessage = $"Saved post {SelectedPost.Id}."; - } - - await RefreshPostsAsync(); - }); - } - - [RelayCommand] - internal async Task DeleteAsync() - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Select an existing post before deleting."; - return; - } - - await ExecuteAsync(async () => - { - await BlogClient!.DeletePostAsync(SelectedPost.Id); - StatusMessage = $"Deleted post {SelectedPost.Id}."; - SelectedPost = null; - await RefreshPostsAsync(); - }); - } - - /// - /// Toggle the publication state of the currently selected - /// post. Pushes the new state to - /// PUT /api/BlogApi/{id}/publish and reflects it - /// locally in + the - /// selected post so the UI updates without a full - /// refresh. - /// - /// The toggle is its own action — separate from Save - /// — because Publish is not part of the - /// BlogPostDto payload. Bundling it into Save - /// would require a wire-shape change and a second server - /// overload; the dedicated endpoint keeps the wire - /// contract clean. - /// - public async Task SetPublishStateAsync(bool publish) - { - if (SelectedPost is null || SelectedPost.Id == 0) - { - StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; - return; - } - - await ExecuteAsync(async () => - { - // The checkbox updates DraftIsPublished before the command is - // executed. Using the current bound value avoids the - // double-toggle bug in which the UI has already flipped the - // state and the command flips it again. - await BlogClient!.SetPublishAsync(SelectedPost.Id, publish); - DraftIsPublished = publish; - // Mirror into the selected post so a subsequent - // RefreshPostsAsync() doesn't blow away the - // locally flipped state until the round-trip - // re-hydrates it. - SelectedPost.IsPublished = publish; - StatusMessage = publish - ? $"Billet {SelectedPost.Id} publié." - : $"Billet {SelectedPost.Id} remis en brouillon."; - }); - } - - [RelayCommand] - internal async Task TogglePublishAsync() - { - await SetPublishStateAsync(DraftIsPublished); - } - - /// - /// DEV ONLY: open the signature capture page. The production - /// entry point is a SignalR push from Yavsc.Org ("devis - /// received, sign here"); this command is the dev-time - /// shortcut to reach the page without that infrastructure. - /// Aligned on the same VM-first navigation pattern as - /// : the VM resolves the target VM - /// through , the ViewLocator picks - /// the matching Control at bind time. No - /// Click handler, no App.ServiceProvider - /// access from the view layer. - /// - [RelayCommand] - internal async Task OpenSignatureDevAsync() - { - await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); - } - - - [RelayCommand(CanExecute = nameof(CanManageAcl))] - public async Task ManageAclAsync() - { - if (SelectedPost is null) - { - StatusMessage = "Select an existing post before managing ACL."; - return; - } - - var postForAcl = SelectedPost; - try - { - var detailed = await BlogClient!.GetPostAsync(SelectedPost.Id).ConfigureAwait(true); - if (detailed is not null) - { - postForAcl = detailed; - SelectedPost = detailed; - } - } - catch - { - // Keep the dialog usable even if the detail refresh fails. - } - - await ((App)App.Current!).PushPageAsync(GetACLViewModel(postForAcl)).ConfigureAwait(true); - } - - [RelayCommand] - public async Task OpenCirclesAsync() - { - var circlesVm = ResolveServices().GetRequiredService(); - await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); - } - - private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) - { - var sp = ResolveServices(); - var aclClient = sp.GetRequiredService(); - var circleClient = sp.GetRequiredService(); - return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); - } - /// /// API surface that hits the Yavsc.Blogs deployment at /// . Owned and constructed by @@ -342,18 +130,17 @@ public partial class MainViewModel : ViewModelBase private void Init(Settings? settings) { + SearchText = string.Empty; Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; Settings = settings ?? new Settings(); - SearchText = Settings.SearchText; WindowTitle = "PostIt"; DraftTitle = string.Empty; DraftArticle = string.Empty; DraftIsPublished = false; - IsLoaded = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor // without a Settings argument; we fall back to a fresh @@ -365,15 +152,6 @@ public partial class MainViewModel : ViewModelBase // (thread-safe dispatcher marshalling) so the duplicate // instance is now merely wasteful, not dangerous. - Settings.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(Settings.SearchText)) - { - SearchText = Settings.SearchText; - ApplyFilter(); - } - }; - } /// Save is enabled as soon as the user has typed @@ -401,14 +179,7 @@ public partial class MainViewModel : ViewModelBase Init(settings); } - partial void OnSearchTextChanged(string value) - { - if (Settings is not null && Settings.SearchText != value) - { - Settings.SearchText = value; - } - ApplyFilter(); - } + partial void OnSearchTextChanged(string value) => ApplyFilter(); partial void OnSelectedPostChanged(BlogPostDto? value) { @@ -435,6 +206,170 @@ public partial class MainViewModel : ViewModelBase partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); + [RelayCommand] + internal async Task LoadPosts() + { + await ExecuteAsync(async () => + { + var posts = await BlogClient!.GetPostsAsync(); + Posts.Clear(); + foreach (var post in posts.OrderByDescending(p => p.DateModified)) + { + Posts.Add(post); + } + ApplyFilter(); + StatusMessage = $"Loaded {Posts.Count} posts."; + }); + } + + [RelayCommand] + internal void Search() => ApplyFilter(); + + [RelayCommand] + internal async Task Save() + { + // The button is already disabled when the title is empty + // (see CanSave), but the test path (and any programmatic + // ICommand.Execute) bypasses CanExecute, so we still + // guard here. Better to no-op with a status message + // than to send a request the server will reject. + if (string.IsNullOrWhiteSpace(DraftTitle)) + { + StatusMessage = "Title is required."; + return; + } + + await ExecuteAsync(async () => + { + // Build a fresh BlogPostDto from the editor buffer on + // every Save — we no longer mutate SelectedPost in + // place. The previous behaviour copied the buffer + // (which was a no-op when SelectedPost was null) + // back onto the model and relied on a + // [Required] violation to surface the missing + // input; the new shape keeps the editor buffer as + // the single source of truth for outgoing payloads + // and the selected post as a read-only hint for + // the update path. + if (SelectedPost is null || SelectedPost.Id == 0) + { + var draft = new BlogPostDto + { + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + }; + var created = await BlogClient!.CreatePostAsync(draft); + if (created is not null) + { + SelectedPost = created; + StatusMessage = $"Created post {created.Id}."; + } + } + else + { + var update = new BlogPostDto + { + Id = SelectedPost.Id, + AuthorId = SelectedPost.AuthorId, + Photo = SelectedPost.Photo, + Title = DraftTitle, + Article = DraftArticle ?? string.Empty, + DateCreated = SelectedPost.DateCreated, + DateModified = DateTime.UtcNow, + }; + await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); + StatusMessage = $"Saved post {SelectedPost.Id}."; + } + + await RefreshPostsAsync(); + }); + } + + [RelayCommand] + internal async Task Delete() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Select an existing post before deleting."; + return; + } + + await ExecuteAsync(async () => + { + await BlogClient!.DeletePostAsync(SelectedPost.Id); + StatusMessage = $"Deleted post {SelectedPost.Id}."; + SelectedPost = null; + await RefreshPostsAsync(); + }); + } + + /// + /// Toggle the publication state of the currently selected + /// post. Pushes the new state to + /// PUT /api/BlogApi/{id}/publish and reflects it + /// locally in + the + /// selected post so the UI updates without a full + /// refresh. + /// + /// The toggle is its own action — separate from Save + /// — because Publish is not part of the + /// BlogPostDto payload. Bundling it into Save + /// would require a wire-shape change and a second server + /// overload; the dedicated endpoint keeps the wire + /// contract clean. + /// + [RelayCommand] + internal async Task TogglePublish() + { + if (SelectedPost is null || SelectedPost.Id == 0) + { + StatusMessage = "Sélectionnez un billet existant pour changer sa publication."; + return; + } + + await ExecuteAsync(async () => + { + var desired = !DraftIsPublished; + await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); + DraftIsPublished = desired; + // Mirror into the selected post so a subsequent + // RefreshPostsAsync() doesn't blow away the + // locally flipped state until the round-trip + // re-hydrates it. + SelectedPost.IsPublished = desired; + StatusMessage = desired + ? $"Billet {SelectedPost.Id} publié." + : $"Billet {SelectedPost.Id} remis en brouillon."; + }); + } + + /// + /// DEV ONLY: open the signature capture page. The production + /// entry point is a SignalR push from Yavsc.Org ("devis + /// received, sign here"); this command is the dev-time + /// shortcut to reach the page without that infrastructure. + /// Aligned on the same VM-first navigation pattern as + /// : the VM resolves the target VM + /// through , the ViewLocator picks + /// the matching Control at bind time. No + /// Click handler, no App.ServiceProvider + /// access from the view layer. + /// + [RelayCommand] + internal async Task OpenSignatureDev() + { + await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); + } + + private ViewModelBase GetACLViewModel(BlogPostDto selectedPost) + { + var sp = ResolveServices(); + var aclClient = sp.GetRequiredService(); + var circleClient = sp.GetRequiredService(); + return new PostAclDialogViewModel(selectedPost, aclClient, circleClient); + } private async Task RefreshPostsAsync() { @@ -491,18 +426,28 @@ public partial class MainViewModel : ViewModelBase private void UpdateCommandStates() { - RefreshCommand.NotifyCanExecuteChanged(); + LoadPostsCommand.NotifyCanExecuteChanged(); SaveCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); } - internal async Task InitializeAsync() + + [RelayCommand(CanExecute = nameof(CanManageAcl))] + public async Task ManageAcl() { - if (!IsLoaded) + if (SelectedPost is null) { - await RefreshAsync(); - IsLoaded = true; + StatusMessage = "Select an existing post before managing ACL."; + return; } + await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true); + } + + [RelayCommand] + public async Task OpenCircles() + { + var circlesVm = ResolveServices().GetRequiredService(); + await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true); } } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs index 60544606..ae9e71d5 100644 --- a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; -using System.Net; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -10,17 +8,9 @@ using Yavsc.Blogspot; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; using Yavsc.Abstract.BlogSpot; -using Yavsc.Abstract.Identity.Security; -using System.Net.Http; namespace PostIt.ViewModels; -public sealed class PostAclEntry -{ - public long CircleId { get; init; } - public string CircleName { get; init; } = string.Empty; -} - /// /// View model for the "Gérer l'ACL" modal of a single blog post. /// @@ -51,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase MyCircles { get; set; } = new(); [ObservableProperty] - public partial ObservableCollection + public partial ObservableCollection AclEntries { get; set; } = new(); [ObservableProperty] @@ -87,9 +77,6 @@ public partial class PostAclDialogViewModel : ViewModelBase Post = post ?? throw new ArgumentNullException(nameof(post)); _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); - - AclEntries = new ObservableCollection(post.GetACL().Select(a => ToAclEntry(a.CircleId))); - SelectedCircleToAdd = null; } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } @@ -103,17 +90,16 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - // Load circles for the picker. ACL entries come from the - // BlogPostDto detail payload (source of truth for initial state). + // Load circles and ACL entries in parallel — both are + // independent reads on the same host. The caller's uid + // is implicit in both endpoints. var circlesTask = _circleClient.GetMyCirclesAsync(); - await Task.WhenAll(circlesTask); + var aclTask = _aclClient.GetMyAclAsync(); + await Task.WhenAll(circlesTask, aclTask); var circles = circlesTask.Result ?? new List(); MyCircles = new ObservableCollection(circles); - // Resolve labels now that circles are available. - AclEntries = new ObservableCollection(AclEntries.Select(a => ToAclEntry(a.CircleId))); - StatusMessage = $"{AclEntries.Count} autorisation(s)"; _loaded = true; @@ -140,20 +126,14 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = true; try { - if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id)) - { - StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé"; - return; - } - - var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload + var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload { CircleId = SelectedCircleToAdd.Id, BlogPostId = Post.Id }); if (created is not null) { - AclEntries.Add(ToAclEntry(created.CircleId)); + AclEntries.Add(created); StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; } else @@ -161,13 +141,6 @@ public partial class PostAclDialogViewModel : ViewModelBase StatusMessage = "Autorisation refusée par le serveur"; } } - catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict) - { - // Conflict means the link already exists in backend. Resync - // from the dedicated ACL API so the UI reflects server truth. - await ReloadAclEntriesFromServerAsync(); - StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé"; - } catch (Exception ex) { StatusMessage = $"Erreur: {ex.Message}"; @@ -179,16 +152,14 @@ public partial class PostAclDialogViewModel : ViewModelBase } [RelayCommand] - public async Task RevokeAsync(PostAclEntry? acl) + public async Task RevokeAsync(PostAccessControlRulePayload? acl) { if (acl is null) return; IsBusy = true; try { await _aclClient.RevokeAsync(acl.CircleId); - var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId); - if (existing is not null) - AclEntries.Remove(existing); + AclEntries.Remove(acl); StatusMessage = "Autorisation révoquée"; } catch (Exception ex) @@ -200,26 +171,4 @@ public partial class PostAclDialogViewModel : ViewModelBase IsBusy = false; } } - - private async Task ReloadAclEntriesFromServerAsync() - { - var allAcl = await _aclClient.GetMyAclAsync(); - var currentPostAcl = (allAcl ?? new List()) - .Where(a => a.BlogPostId == Post.Id) - .Select(a => ToAclEntry(a.CircleId)) - .GroupBy(a => a.CircleId) - .Select(g => g.First()) - .ToList(); - AclEntries = new ObservableCollection(currentPostAcl); - } - - private PostAclEntry ToAclEntry(long circleId) - { - var circleName = MyCircles.FirstOrDefault(c => c.Id == circleId)?.Name; - return new PostAclEntry - { - CircleId = circleId, - CircleName = string.IsNullOrWhiteSpace(circleName) ? $"Cercle #{circleId}" : circleName - }; - } } diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs similarity index 90% rename from src/PostIt/PostIt/ViewModels/Settings/Settings.cs rename to src/PostIt/PostIt/ViewModels/Settings.cs index f942249b..2115a0cd 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -2,11 +2,13 @@ using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using IdentityModel.OidcClient; +using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Text.Json; +using System.Threading; [assembly: InternalsVisibleTo("PostIt.Tests")] @@ -16,6 +18,37 @@ public partial class Settings : ViewModelBase { const string SettingsFileName = "postit-settings.json"; + /// + /// Redirect URI used by the Android app. The corresponding IntentFilter + /// in PostIt.Android/Properties/AndroidManifest.xml must match. + /// + public const string AndroidRedirectUri = "android://postit-signin"; + + + + /// + /// Process-wide canonical instance, wired up + /// at application boot by + /// through . The hybrid pattern: + /// + /// The static Current reference gives + /// ViewModels a non-DI way to reach the same instance (and lets + /// the framework bindings push notifications through one stable + /// ). + /// Tests that want to exercise a clean + /// instance still call new Settings(); Current + /// stays null in those contexts because + /// is never invoked. + /// Reads () are + /// thread-safe and never allocate; mutations always go through + /// the DI-resolved singleton so two threads cannot each register + /// a different "current" Settings. + /// + /// + private static Settings? s_current; + + + [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); @@ -28,15 +61,12 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/"; - [ObservableProperty] - public partial string SearchText { get; set; } = string.Empty; - /// /// Catch top-level mutations: the four ObservableProperty /// setters above all funnel through here, and we flip /// in lock-step. Sub-property mutations /// (e.g. Authentication.Authority) are caught by the - /// subscription wired up in + /// subscription wired up in /// below. disables the flag during bulk /// hydration so the disk load itself does not count as a user /// edit. @@ -46,7 +76,6 @@ public partial class Settings : ViewModelBase partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnBusinessApiUrlChanged(string value) => MarkDirty(); - partial void OnSearchTextChanged(string value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -299,7 +328,6 @@ public partial class Settings : ViewModelBase { this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; - this.SearchText = settings.SearchText ?? string.Empty; if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings(); @@ -308,13 +336,13 @@ public partial class Settings : ViewModelBase this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ? AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId; this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ? - AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri; + AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri; if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0) { settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes; } else - this.Authentication.Scopes = settings.Authentication.Scopes; + this.Authentication.Scopes = settings.Authentication.Scopes; } } // A disk load (or an embedded-resource fallback) is the @@ -349,11 +377,10 @@ public partial class Settings : ViewModelBase { Authority = AuthenticationSettings.DefaultAuthority, ClientId = AuthenticationSettings.DefaultClientId, - RedirectUri = AuthenticationSettings.DesktopRedirectUri, + RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri, Scopes = AuthenticationSettings.DefaultScopes }; this.DarkMode = false; - this.SearchText = string.Empty; } /// diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 0d2f5411..7f1f1196 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -29,15 +29,15 @@ VerticalAlignment="Top"> - -public class CircleAuthorization +public sealed class CircleAuthorization : ICircleAuthorization { public long CircleId { get; set; } } diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs new file mode 100644 index 00000000..9c16bd3b --- /dev/null +++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs @@ -0,0 +1,8 @@ +namespace Yavsc.Abstract.Identity.Security +{ + + public interface ICircleAuthorization + { + long CircleId { get; set; } + } +} diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs index 6b593f3c..25c21961 100644 --- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs +++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs @@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security bool AuthorizeCircle(long circleId); - ICollection ACL { get; } //ICircleAuthorization [] GetACL(); + ICircleAuthorization [] GetACL(); } } diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj index 78ffe531..f76e42fd 100644 --- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj +++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj @@ -5,13 +5,16 @@ A shared model for a little client/server app, dealing about establishing some contract, between some human client and provider. Yavsc.Abstract - https://forgejo.pschneider.fr/notazof/yavsc + https://github.com/pazof/yavsc true true latest 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 - \ No newline at end of file + + + + diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs index 8284c5ed..cbc82358 100644 --- a/src/Yavsc.Api.Client/BlogApiClient.cs +++ b/src/Yavsc.Api.Client/BlogApiClient.cs @@ -33,7 +33,7 @@ namespace Yavsc.Api.Client; /// public sealed class BlogApiClient { - private const string DefaultPathPrefix = "blogspot"; + private const string DefaultPathPrefix = "blog"; private readonly IYavscApiClient _api; private readonly Uri _baseAddress; diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj index 6ceaab11..00e6e2db 100644 --- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -17,10 +17,13 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 + + + - \ No newline at end of file + diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index c8c30904..d9a814f6 100644 --- a/src/Yavsc.Api/Yavsc.Api.csproj +++ b/src/Yavsc.Api/Yavsc.Api.csproj @@ -7,11 +7,14 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 - \ No newline at end of file + + + + diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs index ab5ebcc6..92dd3b2c 100644 --- a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs @@ -1,12 +1,10 @@ using System.Net; using System.Net.Http.Json; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Yavsc.Abstract.BlogSpot; using Yavsc.Models; using Yavsc.Models.Access; -using Yavsc.Models.Blog; using Yavsc.Tests.Shared; using static Yavsc.Constants; @@ -40,16 +38,15 @@ public sealed class BlogAclApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; + public BlogAclApiTests(BlogsWebServerFixture fixture) { _fixture = fixture; } - private string BlogUrl() - => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}"; private string BlogAclUrl() - => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}"; + => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl"; /// Delete any ACL rows tied to the fixture's seeded /// (CircleId, BlogPostId) pair. The shared SQLite store @@ -123,7 +120,7 @@ public sealed class BlogAclApiTests : IClassFixture // owned by the caller. We seed the same shape pre-POST so the // test reproduces the prod scenario end-to-end. CleanupAcl(); - using var http = NewClient(_fixture.DefaultUserLogin); + using var http = NewClient("alice"); var payload = new PostAccessControlRulePayload { @@ -131,8 +128,7 @@ public sealed class BlogAclApiTests : IClassFixture BlogPostId = _fixture.PostId }; - var response = await http.PostAsJsonAsync( - BlogAclUrl(), payload, + var response = await http.PostAsJsonAsync(BlogAclUrl(), payload, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); @@ -182,7 +178,7 @@ public sealed class BlogAclApiTests : IClassFixture [MemberData(nameof(BlogAclPayloadsForNever500))] public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload) { - using var http = NewClient(_fixture.DefaultUserLogin); + using var http = NewClient("alice"); var response = await http.PostAsJsonAsync( BlogAclUrl(), payload, @@ -220,162 +216,4 @@ public sealed class BlogAclApiTests : IClassFixture ); } - - [Fact] - public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list() - { - CleanupAcl(); - _fixture.SeedUser(_fixture.DefaultUserLogin); - _fixture.SeedUser("tester"); - _fixture.SeedCircle(_fixture.DefaultUserLogin, "test", - false, - new String[] - { - _fixture.DefaultUserLogin, - "tester" - }); - using var http = NewClient(_fixture.DefaultUserLogin ); - - // Create a minimal BlogPost. The server assigns Id, so we - // send 0 + an explicit AuthorId; the production - // BlogSpotService.Create() tolerates that. - var draft = new BlogPost - { - Id = 0, - Title = "Premier billet", - AuthorId = "tester", - Article = "Contenu de test.", - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - ACL = new List( - new CircleAuthorizationToBlogPost[] - { - new CircleAuthorizationToBlogPost - { - CircleId = _fixture.CircleId, - BlogPostId = _fixture.PostId - } - } - ) - }; - - var postResponse = await http.PostAsJsonAsync( - BlogUrl(), - draft, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); - - // The POST returns the server-issued post (with a real Id). - var created = await postResponse.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken - ); - Assert.NotNull(created); - Assert.NotEqual(0, created!.Id); - Assert.Equal(draft.Title, created.Title); - - // The list should now contain exactly one entry. - var listResponse = await http.GetAsync( - BlogUrl(), - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); - - using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync( - TestContext.Current.CancellationToken - )); - Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); - Assert.True(doc.RootElement.GetArrayLength() >= 1); - Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id); - - // detail should return the same post, with ACL and tags. - var detailResponse = await http.GetAsync( - $"{BlogUrl()}/{created.Id}", - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); - using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync( - TestContext.Current.CancellationToken - )); - Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind); - Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64()); - Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var acl)); - Assert.False(detailDoc.RootElement.TryGetProperty("ACL", out _)); - Assert.Equal(JsonValueKind.Array, acl.ValueKind); - Assert.Equal(1, acl.GetArrayLength()); - - var aclEntry = acl[0]; - Assert.Equal(JsonValueKind.Object, aclEntry.ValueKind); - Assert.True(aclEntry.TryGetProperty("circleId", out var circleId)); - Assert.Equal(_fixture.CircleId, circleId.GetInt64()); - } - - [Fact] - public async Task Non_owner_can_read_restricted_post_but_receives_empty_acl_in_list_and_detail() - { - CleanupAcl(); - _fixture.SeedUser(_fixture.DefaultUserLogin); - _fixture.SeedUser("tester"); - _fixture.SeedCircle(_fixture.DefaultUserLogin, "test", false, - new[] { _fixture.DefaultUserLogin, "tester" }); - - using var ownerHttp = NewClient(_fixture.DefaultUserLogin); - using var readerHttp = NewClient("tester"); - - var draft = new BlogPost - { - Id = 0, - Title = "ACL scrub test", - Article = "Visible to circle member", - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - - var postResponse = await ownerHttp.PostAsJsonAsync( - BlogUrl(), - draft, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); - - var created = await postResponse.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - Assert.NotNull(created); - Assert.NotEqual(0, created!.Id); - - var grantResponse = await ownerHttp.PostAsJsonAsync( - BlogAclUrl(), - new PostAccessControlRulePayload - { - CircleId = _fixture.CircleId, - BlogPostId = created.Id - }, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, grantResponse.StatusCode); - - var listResponse = await readerHttp.GetAsync( - BlogUrl(), - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); - - using var listDoc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync( - TestContext.Current.CancellationToken)); - Assert.Equal(JsonValueKind.Array, listDoc.RootElement.ValueKind); - foreach (var listed in listDoc.RootElement.EnumerateArray()) - { - var authorId = listed.GetProperty("authorId").GetString(); - if (string.Equals(authorId, "tester", StringComparison.Ordinal)) - continue; - - Assert.True(listed.TryGetProperty("acl", out var listedAcl)); - Assert.Equal(0, listedAcl.GetArrayLength()); - } - - var detailResponse = await readerHttp.GetAsync( - $"{BlogUrl()}/{created.Id}", - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); - - using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync( - TestContext.Current.CancellationToken)); - Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var detailAcl)); - Assert.Equal(0, detailAcl.GetArrayLength()); - } - } diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs index 00e2ea61..f4878860 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs @@ -8,13 +8,11 @@ using Microsoft.IdentityModel.Tokens; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Tests.Shared; -using Yavsc.Blogs.Tests.Fixtures; namespace Yavsc.Blogs.Tests; [Collection("JwtClaimMapping")] -public sealed class BlogApiMappedClaimsTests : -IClassFixture +public sealed class BlogApiMappedClaimsTests : IClassFixture { private readonly MappedClaimsBlogsWebServerFixture _fixture; @@ -81,10 +79,7 @@ IClassFixture DateModified = DateTime.UtcNow }; - var response = await http.PostAsJsonAsync( - _fixture.BlogSpotUrl(), - draft, - TestContext.Current.CancellationToken); + var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, response.StatusCode); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); @@ -98,7 +93,7 @@ IClassFixture ResetDatabase(); using var http = NewClient(subject: "mapped-owner"); - var createdResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost + var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost { Id = 0, Title = "Billet à modifier", @@ -112,7 +107,7 @@ IClassFixture var created = await createdResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(created); - var updateResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost + var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost { Id = created.Id, Title = "Billet modifié", @@ -131,7 +126,7 @@ IClassFixture ResetDatabase(); using var ownerHttp = NewClient(subject: "mapped-owner"); - var createdResponse = await ownerHttp.PostAsJsonAsync(_fixture.BlogSpotUrl(), new BlogPost + var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost { Id = 0, Title = "Billet protégé", @@ -146,7 +141,7 @@ IClassFixture Assert.NotNull(created); using var otherHttp = NewClient(subject: "mapped-other"); - var updateResponse = await otherHttp.PutAsJsonAsync(_fixture.BlogSpotUrl() + $"/{created!.Id}", new BlogPost + var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost { Id = created.Id, Title = "Tentative de modification", diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index 11f5c0a2..5a218316 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -2,11 +2,11 @@ using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Server.Helpers; using Yavsc.Tests.Shared; -using Yavsc.Blogs.Tests.Fixtures; namespace Yavsc.Blogs.Tests; @@ -31,6 +31,18 @@ public sealed class BlogApiTests : IClassFixture _fixture = fixture; } + /// Reset the in-memory database to a known empty state. + /// UseInMemoryDatabase shares its store across the + /// lifetime of the instance, + /// so without a per-test reset the test order would leak + /// state between tests. + private void ResetDatabase() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } /// Reset the database and seed the /// tester row. Required @@ -43,10 +55,18 @@ public sealed class BlogApiTests : IClassFixture /// at SaveChanges and the controller returns 500. private void ResetAndSeedDefaultUser() { - _fixture.ResetDatabase(); + ResetDatabase(); _fixture.SeedUser("tester"); } + /// The fixture's WebApplication is bound to + /// https://localhost:<random> via + /// . We pick the first + /// https URL and append the controller route + /// (/api/v1/blog, matching the production + /// [Route(APIPrefix + "/blog")]). + private string BlogsUrl => + _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; /// Build an authenticated client: a real /// Authorization: Bearer <jwt> header where the JWT @@ -91,11 +111,10 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { - _fixture.ResetDatabase(); + ResetDatabase(); using var http = NewClient(); - var response = await http.GetAsync( - _fixture.BlogSpotUrl(), + var response = await http.GetAsync("/api/v1/blog", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -128,7 +147,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -141,7 +160,7 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(draft.Title, created.Title); // The list should now contain exactly one entry. - var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), + var listResponse = await http.GetAsync("/api/v1/blog", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); @@ -169,7 +188,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -179,7 +198,7 @@ public sealed class BlogApiTests : IClassFixture Assert.NotNull(created); Assert.Equal("tester", created!.AuthorId); - var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), + var listResponse = await http.GetAsync("/api/v1/blog", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); @@ -207,7 +226,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -247,7 +266,7 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlog_returns_401_when_no_token_is_provided() { - _fixture.ResetDatabase(); + ResetDatabase(); using var http = NewAnonymousClient(); // No Authorization header → the JwtBearer middleware @@ -256,7 +275,7 @@ public sealed class BlogApiTests : IClassFixture // the framework returns 401. This is the proof that the // production policy is wired in the test host and not // short-circuited by a test-only auth bypass. - var response = await http.GetAsync(_fixture.BlogSpotUrl(), + var response = await http.GetAsync("/api/v1/blog", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -284,7 +303,7 @@ public sealed class BlogApiTests : IClassFixture DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); @@ -303,13 +322,13 @@ public sealed class BlogApiTests : IClassFixture DateCreated = created.DateCreated, DateModified = DateTime.UtcNow }; - var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}", + var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}", update, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); // The list should now reflect the new title. - var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), + var listResponse = await http.GetAsync("/api/v1/blog", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); using var doc = JsonDocument.Parse( @@ -337,19 +356,19 @@ public sealed class BlogApiTests : IClassFixture DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - var postResponse = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); var created = (await postResponse.Content.ReadFromJsonAsync( TestContext.Current.CancellationToken ))!; - var deleteResponse = await http.DeleteAsync(_fixture.BlogSpotUrl()+$"/{created.Id}", + var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}", TestContext.Current.CancellationToken ); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); // The list should now be empty. - var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), + var listResponse = await http.GetAsync("/api/v1/blog", TestContext.Current.CancellationToken); String response = await listResponse.Content.ReadAsStringAsync( TestContext.Current.CancellationToken @@ -392,7 +411,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); // Dump the body on failure so the test name + the response @@ -424,7 +443,7 @@ public sealed class BlogApiTests : IClassFixture // behaviour so a future change that, say, makes Title // nullable in the model or drops [Required], triggers a // conscious update of the test (and probably of the VM). - _fixture.ResetDatabase(); + ResetDatabase(); using var http = NewClient(subject: "tester"); var draft = new BlogPost @@ -437,7 +456,7 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, + var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken); if (response.StatusCode != HttpStatusCode.BadRequest) diff --git a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs similarity index 90% rename from src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs rename to src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 6e8ae31f..218904ef 100644 --- a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -10,7 +10,7 @@ using Yavsc.Models.Blog; using Yavsc.Models.Relationship; using Yavsc.Services; using Yavsc.Tests.Shared; -using static Yavsc.Constants; + namespace Yavsc.Blogs.Tests; /// @@ -61,7 +61,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture public long CircleId { get; private set; } public long PostId { get; private set; } - public string DefaultUserLogin { get => "alice"; } // A single SqliteConnection held open at the static level, // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its @@ -170,8 +169,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture // PermissionHandler ownership check sees a null // user id and rejects every PUT. options.MapInboundClaims = false; - options.TokenValidationParameters - = new TokenValidationParameters + options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestTokenIssuer.Issuer, @@ -265,27 +263,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture await Task.CompletedTask; return app; } -/// Reset the in-memory database to a known empty state. - /// UseInMemoryDatabase shares its store across the - /// lifetime of the instance, - /// so without a per-test reset the test order would leak - /// state between tests. - public void ResetDatabase() - { - using var scope = Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - } - public void CleanupAcl() - { - using var scope = Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.CircleAuthorizationToBlogPost - .Where(a => a.CircleId == CircleId - && a.BlogPostId == PostId) - .ExecuteDelete(); - } public override void Dispose() { @@ -333,8 +310,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// Optional hook to fill in fields /// like FullName / Avatar / EmailConfirmed /// that downstream tests assert on. - public ApplicationUser SeedUser(string userName, - Action? configure = null) + public ApplicationUser SeedUser(string userName, Action? configure = null) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -375,31 +351,20 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// Create a circle owned by /// directly in the SQLite store and return its server-assigned /// id. - public long SeedCircle(string ownerId, string name, bool isPublic = false, - ICollection members = null - ) + private long SeedCircle(string ownerId, string name, bool isPublic = false) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic }; db.Circle.Add(circle); db.SaveChanges(); - if (members != null && members.Count > 0) - { - foreach (String memberId in members) - { - var member = new CircleMember { CircleId = circle.Id, MemberId = memberId }; - db.CircleMembers.Add(member); - } - db.SaveChanges(); - } return circle.Id; } /// Create a blog post owned by /// directly in the SQLite store and return its server-assigned /// id. - public long SeedBlogPost(string authorId, string title) + private long SeedBlogPost(string authorId, string title) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -415,5 +380,4 @@ public sealed class BlogsWebServerFixture : WebHostFixture db.SaveChanges(); return post.Id; } - } diff --git a/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs similarity index 97% rename from src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs rename to src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs index 7311ce04..127f38fe 100644 --- a/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs @@ -21,11 +21,11 @@ namespace Yavsc.Blogs.Tests; /// This is the closest in-process reproduction of the production /// authentication surface for the blog API. /// -public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFixture +public sealed class MappedClaimsBlogsWebServerFixture : IDisposable { private readonly InMemoryDatabaseRoot _inMemoryRoot = new(); private readonly Dictionary _savedInboundMap; - private WebApplication? _app = null; + private readonly WebApplication _app; public MappedClaimsBlogsWebServerFixture() { @@ -86,7 +86,6 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix public void Dispose() { - if (_app is null) return; _app.StopAsync().GetAwaiter().GetResult(); _app.DisposeAsync().AsTask().GetAwaiter().GetResult(); @@ -97,7 +96,6 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix } } - private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager { public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) diff --git a/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs b/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs index 113707ca..7d5a02a9 100644 --- a/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs +++ b/src/Yavsc.Blogs.Tests/PublishEndpointTests.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Tests.Shared; -using Yavsc.Blogs.Tests.Fixtures; namespace Yavsc.Blogs.Tests; @@ -72,6 +71,12 @@ public sealed class PublishEndpointTests : IClassFixture return post.Id; } + private string PublishUrl(long id) + => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/v1/blog/{id}/publish"; + + private string BlogsUrl + => _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; + private HttpClient NewClient(string subject) { var handler = new HttpClientHandler @@ -95,13 +100,12 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("alice"); - var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); - var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken); + var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, get.StatusCode); using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.Equal($"post-by-alice", doc.RootElement.GetProperty("title").GetString()); Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean()); } @@ -112,12 +116,11 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("alice"); - await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); - var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken); + await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); - var get = await http.GetAsync(_fixture.BlogSpotUrl() + $"/{postId}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, get.StatusCode); + var get = await http.GetAsync($"{BlogsUrl}/{postId}", TestContext.Current.CancellationToken); using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean()); } @@ -127,7 +130,7 @@ public sealed class PublishEndpointTests : IClassFixture { ResetDatabase(); using var http = NewClient("alice"); - var put = await http.PutAsJsonAsync(_fixture.PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, put.StatusCode); } @@ -138,7 +141,7 @@ public sealed class PublishEndpointTests : IClassFixture var postId = SeedPost("alice"); using var http = NewClient("bob"); - var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); + var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); // 401 Challenge (the controller returns Challenge() // for AuthorizationFailureException). The exact code // is framework-dependent; what matters is "not 204". diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 88764806..83c0dc37 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -9,7 +9,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -32,4 +32,7 @@ - \ No newline at end of file + + + + diff --git a/src/Yavsc.Blogs/Constants.cs b/src/Yavsc.Blogs/Constants.cs index 9d400032..3e499da4 100644 --- a/src/Yavsc.Blogs/Constants.cs +++ b/src/Yavsc.Blogs/Constants.cs @@ -1,6 +1,6 @@ namespace Yavsc.Blogs; -public static class BlogConstants +public static class Constants { public const string AdminRole = "Admin"; public const string ModeratorRole = "Moderator"; diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index 8c76f7ee..fcd3a336 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -9,7 +9,7 @@ namespace Yavsc.Blogs.Controllers { [Authorize("BlogScope")] [Produces("application/json")] - [Route(APIPrefix + "/" + BlogSpotPath)] + [Route(APIPrefix + "/blog")] public class BlogApiController : Controller { private readonly BlogSpotService blogSpotService; @@ -19,14 +19,14 @@ namespace Yavsc.Blogs.Controllers this.blogSpotService = blogSpotService; } - // GET: api/v1/blogspot + // GET: api/BlogApi [HttpGet] public async Task> GetBlogspot(int start = 0, int take = 25) { return await blogSpotService.Index(User, null, start, take); } - // GET: api/v1/blogspot/5 + // GET: api/BlogApi/5 [HttpGet("{id}", Name = "GetBlog")] public async Task GetBlog([FromRoute] long id) { @@ -43,7 +43,7 @@ namespace Yavsc.Blogs.Controllers return NotFound(); } - return Ok(blog.GetPayload()); + return Ok(blog); } catch (AuthorizationFailureException) { @@ -51,7 +51,7 @@ namespace Yavsc.Blogs.Controllers } } - // PUT: api/v1/blogspot/5 + // PUT: api/BlogApi/5 [HttpPut("{id}")] public async Task PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) { @@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - // POST: api/v1/blogspot + // POST: api/v1/blog [HttpPost] public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) { @@ -116,8 +116,7 @@ namespace Yavsc.Blogs.Controllers : (IFormFileCollection)new FormFileCollection(); var uid = User.GetUserId(); var post = blogSpotService.Create(uid, blog, files); - return CreatedAtRoute("GetBlog", new { id = post.Id }, - post.GetPayload()); + return CreatedAtRoute("GetBlog", new { id = post.Id }, post); } // DELETE: api/BlogApi/5 @@ -136,7 +135,7 @@ namespace Yavsc.Blogs.Controllers } await blogSpotService.Delete(User, id); - return Ok(blog.GetPayload()); + return Ok(blog); } /// diff --git a/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs b/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs index 533e5594..ad6a0893 100644 --- a/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs @@ -6,7 +6,7 @@ using static Yavsc.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route(APIPrefix + "/" + BlogTagPath )] + [Route(APIPrefix + "/blogtags")] public class BlogTagsApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index fafd00ac..524f0471 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -8,7 +8,7 @@ using static Yavsc.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route(APIPrefix +"/" + CirclePath)] + [Route(APIPrefix +"/circle")] public class CircleApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Controllers/CommentsApiController.cs b/src/Yavsc.Blogs/Controllers/CommentsApiController.cs index d0747c29..d4c80f99 100644 --- a/src/Yavsc.Blogs/Controllers/CommentsApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CommentsApiController.cs @@ -11,7 +11,7 @@ namespace Yavsc.Blogs.Controllers { [Authorize] [Produces("application/json")] - [Route(APIPrefix + "/" + CommentsPath)] + [Route(APIPrefix + "/blogcomments")] public class CommentsApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 25521e8a..63b3d977 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -4,15 +4,18 @@ enable 1c73094f-959f-4211-b1a1-6a69b236c283 Yavsc.Blogs - https://forgejo.pschneider.fr/notazof/yavsc + https://github.com/pazof/yavsc true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 - \ No newline at end of file + + + + diff --git a/src/Yavsc.Org.Tests/Directory.Packages.props b/src/Yavsc.Org.Tests/Directory.Packages.props index 5927d2b5..b267eafe 100644 --- a/src/Yavsc.Org.Tests/Directory.Packages.props +++ b/src/Yavsc.Org.Tests/Directory.Packages.props @@ -7,5 +7,7 @@ + + diff --git a/src/Yavsc.Org.Tests/NonRegression/EMailling.cs b/src/Yavsc.Org.Tests/NonRegression/EMailling.cs index fd1fe501..743dbbc1 100644 --- a/src/Yavsc.Org.Tests/NonRegression/EMailling.cs +++ b/src/Yavsc.Org.Tests/NonRegression/EMailling.cs @@ -1,19 +1,8 @@ -using System.ComponentModel.DataAnnotations; -using System.Globalization; -using MailKit.Net.Smtp; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using MimeKit; using Yavsc.Interface; using Yavsc.Interfaces; -using Yavsc.Models.Relationship; using Yavsc.Org.Tests.Fakes; -using Yavsc.Services; -using Yavsc.Settings; -using Yavsc.ViewModels.Account; namespace Yavsc.Org.Tests { @@ -66,97 +55,5 @@ namespace Yavsc.Org.Tests client.Calls.Select(c => c.Kind).ToArray()); Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address); } - - [Fact] - public void RegisterModel_rejects_invalid_email_format() - { - var model = new RegisterModel - { - UserName = "alice", - Email = "this is not an email", - Password = "Password123!", - ConfirmPassword = "Password123!" - }; - - var results = new List(); - var valid = Validator.TryValidateObject( - model, - new ValidationContext(model), - results, - validateAllProperties: true); - - Assert.False(valid); - Assert.Contains(results, r => r.MemberNames.Contains(nameof(RegisterModel.Email))); - } - - [Fact] - public async Task SendEmailAsync_ignores_smtp_recipient_rejection() - { - var sender = new MailSender( - Options.Create(new SiteSettings - { - Title = "Test", - Authority = "example.com", - Owner = new StaticContact { Name = "Test Owner", EMail = "owner@example.com" } - }), - Options.Create(new SmtpSettings - { - Host = "smtp.test.local", - Port = 465, - UserName = "test-user", - Password = "secret" - }), - NullLoggerFactory.Instance, - new TestStringLocalizer(), - new RejectingSmtpClientFactory()); - - var result = await sender.SendEmailAsync( - "Alice", - "contact@pschneider.fr", - "Welcome", - "hello"); - - Assert.Equal(string.Empty, result); - } - - private sealed class RejectingSmtpClientFactory : ISmtpClientFactory - { - public Yavsc.Interfaces.ISmtpClient CreateClient() => new RejectingSmtpClient(); - } - - private sealed class RejectingSmtpClient : Yavsc.Interfaces.ISmtpClient - { - public int Timeout { get; set; } - public void Connect(string host, int port, MailKit.Security.SecureSocketOptions options) { } - public void Authenticate(string userName, string password) { } - public Task SendAsync(MimeMessage message, CancellationToken cancellationToken = default) - { - throw new SmtpCommandException( - SmtpErrorCode.RecipientNotAccepted, - SmtpStatusCode.MailboxUnavailable, - "Recipient address rejected: User unknown in local recipient table"); - } - public void Disconnect(bool quit) { } - public void Dispose() { } - } - - private sealed class TestStringLocalizer : IStringLocalizer - { - public LocalizedString this[string name] => new(name, name); - public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(CultureInfo.InvariantCulture, name, arguments)); - - public IEnumerable GetAllStrings(bool includeParentCultures) - => Enumerable.Empty(); - - public LocalizedString GetString(string name) - => new(name, name); - - public LocalizedString GetString(string name, params object[] arguments) - => new(name, string.Format(CultureInfo.InvariantCulture, name, arguments)); - - public IStringLocalizer WithCulture(CultureInfo culture) - => this; - } - } } diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 10607920..d332d250 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -11,7 +11,7 @@ $(MSBuildProjectDirectory)\test.runsettings 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -86,4 +86,7 @@ - \ No newline at end of file + + + + diff --git a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs index 05cb55b5..c562736d 100644 --- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs +++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs @@ -564,8 +564,6 @@ IHtmlLocalizerFactory htmlLocalizerFactory, [ValidateAntiForgeryToken] public async Task Register(RegisterModel model) { - model.Email = model.Email?.Trim(); - if (ModelState.IsValid) { var user = new ApplicationUser { UserName = model.UserName, Email = model.Email }; diff --git a/src/Yavsc.Org/Directory.Packages.props b/src/Yavsc.Org/Directory.Packages.props index d9925e02..fd16374b 100644 --- a/src/Yavsc.Org/Directory.Packages.props +++ b/src/Yavsc.Org/Directory.Packages.props @@ -20,5 +20,6 @@ + diff --git a/src/Yavsc.Org/Services/BlogSpotService.cs b/src/Yavsc.Org/Services/BlogSpotService.cs index 39e9329f..7eac07a1 100644 --- a/src/Yavsc.Org/Services/BlogSpotService.cs +++ b/src/Yavsc.Org/Services/BlogSpotService.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Yavsc.Blogspot; using Yavsc.Models; -using Yavsc.Models.Access; using Yavsc.Models.Blog; using Yavsc.Server.Exceptions; using Yavsc.Server.Helpers; @@ -98,7 +97,6 @@ public class OldBlogSpotService throw new AuthorizationFailureException(auth); } var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id); - ScrubAclForViewer(blog, user); return new BlogPostEditViewModel(blog, pub); } @@ -120,7 +118,6 @@ public class OldBlogSpotService { throw new AuthorizationFailureException(auth); } - ScrubAclForViewer(blog, user); foreach (var c in blog.Comments) { c.Author = _context.Users.First(u => u.Id == c.AuthorId); @@ -192,14 +189,13 @@ public class OldBlogSpotService public async Task> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25) { - string? viewerId = user.Identity?.IsAuthenticated == true ? user.GetUserId() : null; IEnumerable posts; if (user.Identity.IsAuthenticated) { - string viewerIdNonNull = viewerId!; + string viewerId = user.GetUserId(); long[] userCircles = await _context.Circle.Include(c => c.Members). - Where(c => c.Members.Any(m => m.MemberId == viewerIdNonNull)) + Where(c => c.Members.Any(m => m.MemberId == viewerId)) .Select(c => c.Id).ToArrayAsync(); posts = _context.BlogSpot @@ -209,7 +205,7 @@ public class OldBlogSpotService .Include(p => p.Comments) .Where(p => p.ACL == null || p.ACL.Count == 0 - || (p.AuthorId == viewerIdNonNull) + || (p.AuthorId == viewerId) || (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId))) ); @@ -227,11 +223,7 @@ public class OldBlogSpotService .Select(p => p.BlogPost).ToArray(); } - var materialised = posts.ToList(); - foreach (var post in materialised.OfType()) - ScrubAclForViewer(post, user); - - var data = materialised.OrderByDescending(p => p.DateModified) + var data = posts.OrderByDescending(p => p.DateModified) .Skip(skip) .Take(take); return data; @@ -254,11 +246,7 @@ public class OldBlogSpotService { string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; if (posterId == null) return Array.Empty(); - var posts = _context.UserPosts(posterId, readerId).ToList(); - var viewerId = string.Equals(readerId, posterId, StringComparison.Ordinal) ? readerId : null; - foreach (var post in posts) - ScrubAclForViewer(post, viewerId); - return posts; + return _context.UserPosts(posterId, readerId); } public object? GetTitle(string title) @@ -278,39 +266,4 @@ public class OldBlogSpotService .SingleOrDefaultAsync(x => x.Id == value); } - private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user) - { - if (!IsOwner(post, user)) - post.ACL = new List(); - } - - private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, string? viewerId) - { - if (!string.Equals(post.AuthorId, viewerId, StringComparison.Ordinal) - && !string.Equals(post.Author?.Id, viewerId, StringComparison.Ordinal)) - post.ACL = new List(); - } - - private static bool IsOwner(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user) - { - if (user?.Identity?.IsAuthenticated != true) return false; - - var viewerId = user.GetUserId(); - var viewerName = user.GetUserName() ?? user.Identity?.Name; - - if (!string.IsNullOrWhiteSpace(viewerId)) - { - if (string.Equals(post.AuthorId, viewerId, StringComparison.Ordinal)) return true; - if (string.Equals(post.Author?.Id, viewerId, StringComparison.Ordinal)) return true; - } - - if (!string.IsNullOrWhiteSpace(viewerName)) - { - if (string.Equals(post.AuthorId, viewerName, StringComparison.OrdinalIgnoreCase)) return true; - if (string.Equals(post.Author?.UserName, viewerName, StringComparison.OrdinalIgnoreCase)) return true; - } - - return false; - } - } diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml index 1582041c..52cf3b88 100644 --- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml @@ -70,7 +70,7 @@
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded) { - Details + Details } else { diff --git a/src/Yavsc.Org/Views/Home/About.pt.cshtml b/src/Yavsc.Org/Views/Home/About.pt.cshtml index 487a1ab3..cca60a94 100755 --- a/src/Yavsc.Org/Views/Home/About.pt.cshtml +++ b/src/Yavsc.Org/Views/Home/About.pt.cshtml @@ -93,8 +93,8 @@ A operação é anulável até duas semanas após a sua programação. Este é o meu site perso, uma configuração de _Yavsc_ (outro negócio muito pequeno). -* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) -* [licença: GNU GPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) +* [README](https://github.com/pazof/yavsc/blob/vnext/README.md) +* [licença: GNU GPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE) Outras instalações: @@ -109,8 +109,8 @@ Outras instalações: Yet Another Very Small Company ... -* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) -* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) +* [README](https://github.com/pazof/yavsc/blob/vnext/README.md) +* [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE) @@ -118,8 +118,8 @@ Outras instalações: ## Yet Another Very Small Company : -* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) -* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) +* [README](https://github.com/pazof/yavsc/blob/vnext/README.md) +* [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE) En production: diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index dda2d10c..0403c7eb 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -9,7 +9,7 @@ https://github.com/pazof/yavsc 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -51,4 +51,7 @@ - \ No newline at end of file + + + + diff --git a/src/Yavsc.Server/Helpers/PayloadHelpers.cs b/src/Yavsc.Server/Helpers/PayloadHelpers.cs deleted file mode 100644 index cd359bb4..00000000 --- a/src/Yavsc.Server/Helpers/PayloadHelpers.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Yavsc.Models.Blog; - -public static class PayloadHelpers -{ - public static object GetPayload(this BlogPost post) - { - return new - { - post.Id, - post.Title, - post.Article, - post.DateCreated, - post.UserCreated, - post.DateModified, - post.UserModified, - post.AuthorId, - ACL = post.GetACL(), - Tags = post.GetTags(), - post.IsPublished - }; - } -} diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index 4c2f2ae5..5a2dc58d 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -66,9 +66,9 @@ namespace Yavsc.Models.Blog return ACL?.Any(i => i.CircleId == circleId) ?? true; } - public CircleAuthorization[] GetACL() + public ICircleAuthorization[] GetACL() { - return ACL?.ToArray() ?? Array.Empty(); + return ACL?.ToArray() ?? Array.Empty(); } public void Tag(Tag tag) @@ -85,7 +85,7 @@ namespace Yavsc.Models.Blog public string[] GetTags() { - return Tags?.Select(t => t.Tag.Name).ToArray() ?? Array.Empty(); + return Tags.Select(t => t.Tag.Name).ToArray(); } [InverseProperty("Post")] @@ -106,7 +106,6 @@ namespace Yavsc.Models.Blog [NotMapped] public bool IsPublished { get; set; } - [JsonIgnore] /// /// Explicit interface implementation of /// . The underlying @@ -134,16 +133,5 @@ namespace Yavsc.Models.Blog }; } } - - ICollection ICircleAuthorized.ACL - { - get - { - return ACL?.Select(a => new CircleAuthorization - { - CircleId = a.CircleId - }).ToList() ?? new List(); - } - } } } diff --git a/src/Yavsc.Server/Models/Blog/BlogTag.cs b/src/Yavsc.Server/Models/Blog/BlogTag.cs index 1f15a081..69d428de 100644 --- a/src/Yavsc.Server/Models/Blog/BlogTag.cs +++ b/src/Yavsc.Server/Models/Blog/BlogTag.cs @@ -1,17 +1,14 @@ using System.ComponentModel.DataAnnotations.Schema; -using System.Text.Json.Serialization; using Yavsc.Models.Relationship; namespace Yavsc.Models.Blog { public partial class BlogTag { - [JsonIgnore] [ForeignKey("PostId")] public virtual BlogPost Post { get; set; } public long PostId { get; set; } - [JsonIgnore] [ForeignKey("TagId")] public virtual Tag Tag{ get; set; } public long TagId { get; set; } diff --git a/src/Yavsc.Server/Models/Blog/Comment.cs b/src/Yavsc.Server/Models/Blog/Comment.cs index 4d39d0c2..acb5e003 100644 --- a/src/Yavsc.Server/Models/Blog/Comment.cs +++ b/src/Yavsc.Server/Models/Blog/Comment.cs @@ -13,17 +13,15 @@ namespace Yavsc.Models.Blog [YaStringLength(1024)] public string Article { get; set; } - - [JsonIgnore] - [ForeignKeyAttribute(nameof(ReceiverId))] + + [ForeignKeyAttribute(nameof(ReceiverId))][JsonIgnore] public virtual BlogPost Post { get; set; } [Required] public long ReceiverId { get; set; } public bool Visible { get; set; } - [ForeignKeyAttribute("AuthorId")] - [JsonIgnore] + [ForeignKeyAttribute("AuthorId")][JsonIgnore] public virtual ApplicationUser Author { get; set; } diff --git a/src/Yavsc.Server/Services/BlogSpotService.cs b/src/Yavsc.Server/Services/BlogSpotService.cs index a0832630..6b85a4c3 100644 --- a/src/Yavsc.Server/Services/BlogSpotService.cs +++ b/src/Yavsc.Server/Services/BlogSpotService.cs @@ -1,16 +1,15 @@ using System.Diagnostics; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using Yavsc.Blogspot; using Yavsc.Models; -using Yavsc.Models.Access; using Yavsc.Models.Blog; using Yavsc.Server.Exceptions; using Yavsc.Server.Helpers; using Yavsc.Services; using Yavsc.ViewModels.Auth; +using Microsoft.AspNetCore.Http; +using Yavsc.Blogspot; public class BlogSpotService { @@ -18,25 +17,24 @@ public class BlogSpotService private readonly IAuthorizationService _authorizationService; private readonly IFileSystemAuthManager fileSystemAuthManager; - public BlogSpotService( - ApplicationDbContext context, - IAuthorizationService authorizationService, - IFileSystemAuthManager fileSystemAuthManager) + public BlogSpotService(ApplicationDbContext context, + IAuthorizationService authorizationService, + IFileSystemAuthManager fileSystemAuthManager) { _authorizationService = authorizationService; _context = context; this.fileSystemAuthManager = fileSystemAuthManager; } - public BlogPost Create(string userId, BlogPost post, IFormFileCollection files) + public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files) { // Sauvegarder le post d'abord pour obtenir son ID - // Le createur vient de l'authentification, donc on ne le prend pas du post + // Le créateur vient de l'authentification, donc on ne le prend pas du post post.AuthorId = userId; _context.BlogSpot.Add(post); _context.SaveChanges(userId); - // Traiter les fichiers attaches s'il y en a + // Traiter les fichiers attachés s'il y en a if (files != null && files.Count > 0) { var user = _context.Users.FirstOrDefault(u => u.Id == userId); @@ -44,19 +42,23 @@ public class BlogSpotService { try { + // Créer un répertoire pour les fichiers du blog string blogFilesSubdir = $"blogs/{post.Id}"; string destDir = Path.Combine( AbstractFileSystemHelpers.UserFilesDirName, user.UserName, - blogFilesSubdir); + blogFilesSubdir + ); var di = new DirectoryInfo(destDir); if (!di.Exists) di.Create(); + // Traiter chaque fichier foreach (var formFile in files) { var fileInfo = user.ReceiveUserFile(destDir, formFile); if (fileInfo != null && !fileInfo.QuotaOffense) { + // Créer une entrée UploadedFile si nécessaire var uploadedFile = new UploadedFile { Path = fileInfo.FileName, @@ -66,6 +68,7 @@ public class BlogSpotService _context.UploadedFiles.Add(uploadedFile); _context.SaveChanges(userId); + // Lier le fichier au post var attachment = new BlogAttachedFile { PostId = post.Id, @@ -78,56 +81,52 @@ public class BlogSpotService } catch (Exception ex) { - Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); + // Logger l'erreur mais ne pas échouer la création du post + System.Diagnostics.Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); } } } return post; } - public async Task GetPostForEdition(ClaimsPrincipal user, long blogPostId) { - var blog = await _context.BlogSpot - .Include(x => x.Author) - .Include(x => x.ACL) - .SingleAsync(m => m.Id == blogPostId); - - var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); + var blog = await _context.BlogSpot.Include(x => x.Author).Include(x => x.ACL).SingleAsync(m => m.Id == blogPostId); + var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); if (!auth.Succeeded) + { throw new AuthorizationFailureException(auth); - + } var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id); - ScrubAclForViewer(blog, user); return new BlogPostEditViewModel(blog, pub); } - public async Task Details(ClaimsPrincipal user, long blogPostId) + public async Task Details(ClaimsPrincipal user, long blogPostId) { - BlogPost blog = await _context.BlogSpot - .Include(p => p.Author) - .Include(p => p.Tags) - .Include(p => p.Comments) - .Include(p => p.ACL) - .SingleAsync(m => m.Id == blogPostId); - + Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot + .Include(p => p.Author) + .Include(p => p.Tags) + .Include(p => p.Comments) + .Include(p => p.ACL) + .SingleAsync(m => m.Id == blogPostId); if (blog == null) + { return null; - - // Hydrate le flag [NotMapped] depuis la table de publication. + } + // Hydrate the [NotMapped] IsPublished flag from the + // publication table so the wire JSON carries it. blog.IsPublished = await _context.blogSpotPublications .AnyAsync(pub => pub.BlogpostId == blogPostId); - var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission()); if (!auth.Succeeded) + { throw new AuthorizationFailureException(auth); - - ScrubAclForViewer(blog, user); - + } foreach (var c in blog.Comments) + { c.Author = _context.Users.First(u => u.Id == c.AuthorId); - + } return blog; } @@ -135,45 +134,54 @@ public class BlogSpotService { var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id); Debug.Assert(blog != null); - var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); if (!auth.Succeeded) + { throw new AuthorizationFailureException(auth); - + } blog.Article = blogEdit.Article; blog.Title = blogEdit.Title; blog.Photo = blogEdit.Photo; blog.ACL = blogEdit.ACL; + // saves the change _context.Update(blog); - - var publication = await _context.blogSpotPublications - .SingleOrDefaultAsync(p => p.BlogpostId == blogEdit.Id); - + var publication = await _context.blogSpotPublications.SingleOrDefaultAsync + (p => p.BlogpostId == blogEdit.Id); if (publication != null) { if (!blogEdit.Publish) + { _context.blogSpotPublications.Remove(publication); + } } - else if (blogEdit.Publish) + else { - _context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = blogEdit.Id }); + if (blogEdit.Publish) + { + _context.blogSpotPublications.Add( + new BlogSpotPublication + { + BlogpostId = blogEdit.Id + } + ); + } } - _context.SaveChanges(user.GetUserId()); } - public async Task Modify(ClaimsPrincipal user, BlogPost blog) + public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog) { - var existing = await _context.BlogSpot - .Include(b => b.ACL) - .SingleOrDefaultAsync(b => b.Id == blog.Id); - + var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id); if (existing == null) + { throw new InvalidOperationException($"Blog post {blog.Id} not found."); + } var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission()); if (!auth.Succeeded) + { throw new AuthorizationFailureException(auth); + } existing.Title = blog.Title; existing.Article = blog.Article; @@ -191,10 +199,9 @@ public class BlogSpotService if (user.Identity.IsAuthenticated) { string viewerId = user.GetUserId(); - long[] userCircles = await _context.Circle.Include(c => c.Members) - .Where(c => c.Members.Any(m => m.MemberId == viewerId)) - .Select(c => c.Id) - .ToArrayAsync(); + long[] userCircles = await _context.Circle.Include(c => c.Members). + Where(c => c.Members.Any(m => m.MemberId == viewerId)) + .Select(c => c.Id).ToArrayAsync(); posts = _context.BlogSpot .Include(b => b.Author) @@ -202,25 +209,34 @@ public class BlogSpotService .Include(p => p.Tags) .Include(p => p.Comments) .Where(p => p.ACL == null - || p.ACL.Count == 0 - || p.AuthorId == viewerId - || (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId)))); + || p.ACL.Count == 0 + || (p.AuthorId == viewerId) + || (userCircles != null && + p.ACL.Any(a => userCircles.Contains(a.CircleId))) + ); } else { posts = _context.blogSpotPublications - .Include(p => p.BlogPost) - .Include(b => b.BlogPost.Author) - .Include(p => p.BlogPost.ACL) - .Include(p => p.BlogPost.Tags) - .Include(p => p.BlogPost.Comments) - .Where(p => p.BlogPost.ACL == null || p.BlogPost.ACL.Count == 0) - .Select(p => p.BlogPost) - .ToArray(); + .Include(p => p.BlogPost) + .Include(b => b.BlogPost.Author) + .Include(p => p.BlogPost.ACL) + .Include(p => p.BlogPost.Tags) + .Include(p => p.BlogPost.Comments) + .Where(p => p.BlogPost.ACL == null + || p.BlogPost.ACL.Count == 0) + .Select(p => p.BlogPost).ToArray(); } + // Materialise before hydrating IsPublished: it's a + // computed [NotMapped] property that needs to be set + // on each BlogPost instance after the query runs. var materialised = posts.ToList(); + // Single bulk lookup for the IsPublished flag — avoid + // the N+1 of one AnyAsync per post. The published ids + // are loaded once and matched against the post list + // in memory. var postIds = materialised.Select(p => p.Id).ToList(); if (postIds.Count > 0) { @@ -228,15 +244,11 @@ public class BlogSpotService .Where(pub => postIds.Contains(pub.BlogpostId)) .Select(pub => pub.BlogpostId) .ToListAsync(); - var publishedSet = publishedIds.ToHashSet(); - foreach (var post in materialised.OfType()) + foreach (var post in materialised.OfType()) post.IsPublished = publishedSet.Contains(post.Id); } - foreach (var post in materialised.OfType()) - ScrubAclForViewer(post, user); - return materialised .OrderByDescending(p => p.DateModified) .Skip(skip) @@ -245,45 +257,61 @@ public class BlogSpotService public async Task Delete(ClaimsPrincipal user, long id) { - BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); + var uid = user.GetUserId(); + Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); + _context.BlogSpot.Remove(blog); _context.SaveChanges(user.GetUserId()); } - public async Task> UserPosts(string posterName, string? readerId, int pageLen = 10, int pageNum = 0) + public async Task> UserPosts( + string posterName, + string? readerId, + int pageLen = 10, + int pageNum = 0) { - string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id; - if (posterId == null) return Array.Empty(); - - var posts = _context.UserPosts(posterId, readerId).ToList(); - var isOwnerReader = string.Equals(readerId, posterId, StringComparison.Ordinal); - - foreach (var post in posts) - { - if (!isOwnerReader) - post.ACL = new List(); - } - - return posts; + string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; + if (posterId == null) return Array.Empty(); + return _context.UserPosts(posterId, readerId); } public object? GetTitle(string title) { - return _context.BlogSpot - .Include(b => b.Author) - .Where(x => x.Title == title) - .OrderByDescending(x => x.DateCreated) - .ToList(); + return _context.BlogSpot.Include( + b => b.Author + ).Where(x => x.Title == title).OrderByDescending( + x => x.DateCreated + ).ToList(); } - public async Task GetBlogPostAsync(long value) + public async Task GetBlogPostAsync(long value) { return await _context.BlogSpot - .Include(b => b.Author) - .Include(b => b.ACL) - .SingleOrDefaultAsync(x => x.Id == value); + .Include(b => b.Author) + .Include(b => b.ACL) + .SingleOrDefaultAsync(x => x.Id == value); } + /// + /// Toggle a post's publication state. + /// true adds a row to blogSpotPublications (the post + /// becomes visible to anonymous callers via + /// ); false removes + /// the row if present. + /// + /// The post must already exist (caller must be the + /// author — this is gated by the controller's EditPermission + /// check). Returns false when the post does not exist; true + /// on a successful toggle. + /// + /// This is the same toggle the + /// -flavoured + /// + /// overload performs inline; extracted here so the + /// /api/blog/{id}/publish endpoint can hit it without + /// forcing the caller to round-trip the full BlogPost in + /// the request body. + /// public async Task SetPublishAsync(ClaimsPrincipal user, long postId, bool publish) { var blog = await _context.BlogSpot.SingleOrDefaultAsync(b => b.Id == postId); @@ -291,33 +319,28 @@ public class BlogSpotService var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); if (!auth.Succeeded) + { throw new AuthorizationFailureException(auth); + } - var existing = await _context.blogSpotPublications.SingleOrDefaultAsync(p => p.BlogpostId == postId); + var existing = await _context.blogSpotPublications.SingleOrDefaultAsync( + p => p.BlogpostId == postId); if (publish) { if (existing == null) + { _context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId }); + } } else { if (existing != null) + { _context.blogSpotPublications.Remove(existing); + } } - await _context.SaveChangesAsync(user.GetUserId()); return true; } - private static void ScrubAclForViewer(BlogPost post, ClaimsPrincipal? user) - { - if (!IsOwner(post, user)) - post.ACL = new List(); - } - - private static bool IsOwner(BlogPost post, ClaimsPrincipal? user) - { - if (user?.Identity?.IsAuthenticated != true) return false; - return string.Equals(user.GetUserId(), post.AuthorId, StringComparison.Ordinal); - } } diff --git a/src/Yavsc.Server/Services/MailSender.cs b/src/Yavsc.Server/Services/MailSender.cs index 592a43ba..417fca6b 100644 --- a/src/Yavsc.Server/Services/MailSender.cs +++ b/src/Yavsc.Server/Services/MailSender.cs @@ -1,4 +1,3 @@ -using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -10,7 +9,6 @@ using Yavsc.Settings; using Yavsc.Models; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.Extensions.Localization; -using System.Text.RegularExpressions; using System.Web; namespace Yavsc.Services @@ -55,101 +53,45 @@ namespace Yavsc.Services /// public Task SendEmailAsync(string email, string subject, string htmlMessage) { - return SendEmailAsync(null, email, subject, htmlMessage); - } - - internal static MailboxAddress BuildMailboxAddress(string? displayName, string? rawAddress) - { - if (string.IsNullOrWhiteSpace(rawAddress)) - { - throw new FormatException("Email address is empty."); - } - - var candidate = rawAddress.Trim(); - if (candidate.Contains('<') || candidate.Contains('>')) - { - var emailMatch = Regex.Match(candidate, - @"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", - RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); - - if (emailMatch.Success) - { - candidate = emailMatch.Value; - } - else - { - candidate = candidate.Trim('<', '>', '"', '\''); - } - } - - candidate = candidate.Trim('"', '\'', '<', '>', ' '); - candidate = candidate.Replace(" ", string.Empty); - - if (!MailboxAddress.TryParse(candidate, out var parsedAddress)) - { - throw new FormatException($"Invalid email address '{rawAddress}'."); - } - - var safeName = string.IsNullOrWhiteSpace(displayName) - ? parsedAddress.Name - : displayName.Trim(); - - return new MailboxAddress(safeName ?? string.Empty, parsedAddress.Address); + return SendEmailAsync("", email, subject, htmlMessage); } public async Task SendEmailAsync(string name, string email, string subject, string htmlMessage) { - try + logger.LogInformation($"SendEmail for {email} : {subject}"); + MimeMessage msg = new(); + msg.From.Add(new MailboxAddress(siteSettings.Owner.Name, + siteSettings.Owner.EMail)); + msg.To.Add(new MailboxAddress(name, email)); + TextPart text; + msg.Body = text = new TextPart("html") { - logger.LogInformation($"SendEmail for {email} : {subject}"); - MimeMessage msg = new(); - msg.From.Add(BuildMailboxAddress(siteSettings.Owner.Name, siteSettings.Owner.EMail)); - msg.To.Add(BuildMailboxAddress(name, email)); - TextPart text; - msg.Body = text = new TextPart("html") + Text = $"{htmlMessage}" + }; + + msg.Subject = subject; + msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId( + siteSettings.Authority + ); + using ISmtpClient sc = _smtpClientFactory.CreateClient(); + { + sc.Timeout = 30000; + sc.Connect( + smtpSettings.Host, + smtpSettings.Port, + SecureSocketOptions.Auto + ); + + if (smtpSettings.UserName != null) { - Text = $"{htmlMessage}" - }; - - msg.Subject = subject; - msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId( - siteSettings.Authority - ); - using Yavsc.Interfaces.ISmtpClient sc = _smtpClientFactory.CreateClient(); - { - sc.Timeout = 30000; - sc.Connect( - smtpSettings.Host, - smtpSettings.Port, - SecureSocketOptions.Auto - ); - - if (smtpSettings.UserName != null) - { - sc.Authenticate(smtpSettings.UserName, smtpSettings.Password); - } - - await sc.SendAsync(msg); - logger.LogInformation($"Sent : {msg.MessageId}"); - sc.Disconnect(true); + sc.Authenticate(smtpSettings.UserName, smtpSettings.Password); } - return msg.MessageId; - } - catch (FormatException ex) - { - logger.LogError(ex, "Refusing to send email because the recipient or sender address is malformed. To={To}, From={From}", email, siteSettings.Owner.EMail); - return string.Empty; - } - catch (SmtpCommandException ex) - { - logger.LogError(ex, "SMTP rejected the recipient or sender address. To={To}, Subject={Subject}, Status={Status}, Error={Error}", email, subject, ex.StatusCode, ex.Message); - return string.Empty; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to send email. To={To}, Subject={Subject}", email, subject); - throw; + + await sc.SendAsync(msg); + logger.LogInformation($"Sent : {msg.MessageId}"); + sc.Disconnect(true); } + return msg.MessageId; } public void SendEmailFromCriteria(string Criteria) diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index 5cd7e31c..ac9380a4 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -5,11 +5,11 @@ 53bd70e8-ff81-497a-847f-a15fd8ea7a09 Yavsc.Server true - https://forgejo.pschneider.fr/notazof/yavsc + https://github.com/pazof/yavsc true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -40,4 +40,7 @@ - \ No newline at end of file + + + + diff --git a/src/Yavsc.Tests.Shared/BlogHelpers.cs b/src/Yavsc.Tests.Shared/BlogHelpers.cs deleted file mode 100644 index ef5a7688..00000000 --- a/src/Yavsc.Tests.Shared/BlogHelpers.cs +++ /dev/null @@ -1,31 +0,0 @@ - -namespace Yavsc.Blogs.Tests.Fixtures; -using static Yavsc.Constants; - -public static class BlogHelpers -{ - public static string ApiUrl(this IBackendFixture fixture, string apiSubPath) - { - var secured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("https://")); - if (secured is null) - { - var unsecured = fixture.Addresses.FirstOrDefault(a => a.StartsWith("http://")); - if (unsecured is null) - { - throw new InvalidOperationException("No backend address found"); - } - return $"{unsecured}/{APIPrefix}/{apiSubPath}"; - } - return $"{secured}/{APIPrefix}/{apiSubPath}"; - } - - public static string BlogAclUrl(this IBackendFixture fixture) - => fixture.ApiUrl(BlogAclPath); - - public static string BlogSpotUrl(this IBackendFixture fixture) - => fixture.ApiUrl(BlogSpotPath); - - public static string PublishUrl(this IBackendFixture fixture, long id) - => fixture.ApiUrl(BlogSpotPath) +"/" + id + "/publish"; - -} diff --git a/src/Yavsc.Tests.Shared/IBackendFixture.cs b/src/Yavsc.Tests.Shared/IBackendFixture.cs deleted file mode 100644 index b5040b96..00000000 --- a/src/Yavsc.Tests.Shared/IBackendFixture.cs +++ /dev/null @@ -1,13 +0,0 @@ -public interface IBackendFixture : IDisposable -{ - /// - /// The addresses the fixture bound to. - /// - IReadOnlyList Addresses { get; } - - /// - /// The service provider for the fixture host. - /// - IServiceProvider Services { get; } - -} diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs index f40da18d..417d29d8 100644 --- a/src/Yavsc.Tests.Shared/WebHostFixture.cs +++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs @@ -30,13 +30,14 @@ namespace Yavsc.Tests.Shared; /// mapping are the responsibility of the subclass, through /// . /// -public abstract class WebHostFixture : IBackendFixture +public abstract class WebHostFixture : IDisposable { private static readonly Lazy _selfSignedCertificate = new Lazy(CreateSelfSignedCertificate); private static readonly object _sync = new object(); private static WebApplication? _app; private static bool _isInitialized; + private static int _instanceCount; private static readonly List _sharedAddresses = new(); private static IServiceProvider? _sharedServices; @@ -56,12 +57,11 @@ public abstract class WebHostFixture : IBackendFixture /// successfully and the host is running.
public bool IsInitialized { get; private set; } -#pragma warning disable CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null. protected WebHostFixture() -#pragma warning restore CS8618 // Un champ non-nullable doit contenir une valeur autre que Null lors de la fermeture du constructeur. Envisagez d’ajouter le modificateur « required » ou de déclarer le champ comme pouvant accepter la valeur Null. { lock (_sync) { + _instanceCount++; if (!_isInitialized) { InitializeAsync().GetAwaiter().GetResult(); @@ -110,8 +110,6 @@ public abstract class WebHostFixture : IBackendFixture /// listen port.
protected virtual int HttpsPort => 5101; - public WebApplication App { get; private set; } - private async Task InitializeAsync() { var builder = WebApplication.CreateBuilder(); @@ -124,14 +122,14 @@ public abstract class WebHostFixture : IBackendFixture }); }); - this.App = BuildApp(builder); - this.App = await ConfigurePipelineAsync(this.App); - await this.App.StartAsync(); + var app = BuildApp(builder); + app = await ConfigurePipelineAsync(app); + await app.StartAsync(); - _app = this.App; - _sharedServices = this.App.Services; + _app = app; + _sharedServices = app.Services; - var server = this.App.Services.GetRequiredService(); + var server = app.Services.GetRequiredService(); var addressFeatures = server.Features.Get(); _sharedAddresses.Clear(); if (addressFeatures?.Addresses is not null) @@ -149,14 +147,15 @@ public abstract class WebHostFixture : IBackendFixture { lock (_sync) { - if (!IsInitialized) - throw new InvalidOperationException("Cannot tear down a fixture that has not been initialized."); - this.App.StopAsync().GetAwaiter().GetResult(); - IsInitialized = false; - - _isInitialized = false; - _sharedAddresses.Clear(); - _sharedServices = null; + _instanceCount--; + if (_instanceCount == 0 && _app is not null) + { + _app.StopAsync().GetAwaiter().GetResult(); + _app = null; + _isInitialized = false; + _sharedAddresses.Clear(); + _sharedServices = null; + } } } diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index b7e2e409..6ccd44c0 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -14,7 +14,7 @@ --> 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -23,7 +23,4 @@ - - - - \ No newline at end of file + diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 2e0fdfa8..010c4eaf 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -7,7 +7,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 + 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 1.1.0-beta.1 @@ -22,4 +22,7 @@ - \ No newline at end of file + + + +