Compare commits

..

No commits in common. "main" and "1.0.8-rc2" have entirely different histories.

105 changed files with 1570 additions and 2007 deletions

View file

@ -25,9 +25,9 @@ on:
jobs: jobs:
build: build:
runs-on: docker runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps: steps:
- name: Clone yavsc - name: Clone yavsc
run: | run: |
@ -39,13 +39,10 @@ jobs:
git checkout FETCH_HEAD git checkout FETCH_HEAD
fi fi
git submodule update --init --recursive 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 - name: Test
run: | run: cd /src/_src && dotnet test --no-build --verbosity normal
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 ($?)!"

View file

@ -51,8 +51,6 @@ jobs:
# via l'API REST Forgejo (pas d'actions tierces Node). # via l'API REST Forgejo (pas d'actions tierces Node).
release: release:
runs-on: docker runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps: steps:
- name: Clone du repo au tag demandé - name: Clone du repo au tag demandé
env: env:
@ -68,8 +66,10 @@ jobs:
# WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile).
cd /src 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 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 fi
cd _src cd _src
@ -171,22 +171,37 @@ jobs:
echo "EOF" >> "$GITHUB_ENV" echo "EOF" >> "$GITHUB_ENV"
echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$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: | run: |
cd /src/_src cd /src/_src
dotnet restore 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: | run: |
cd /src/_src cd /src/_src
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.PostIt-Signed.apk
-c Release -r android-arm64 --no-restore -clp:ErrorsOnly if [[ ! -f "$APK" ]]; then
echo "::error::APK not found at $APK"
- name: Build de PostIt.Android x64 ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true
run: | exit 1
cd /src/_src fi
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ cp "$APK" /src/_src/PostIt.Android.apk
-c Release -r android-x64 --no-restore -clp:ErrorsOnly ls -la /src/_src/PostIt.Android.apk
- name: Publier la release Forgejo via l'API REST - name: Publier la release Forgejo via l'API REST
# Pas d'action tierce (pas de Node dans l'image runner). # 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 # sinon curl l'interprète comme un second fichier d'input
# (un fichier nommé '?name=PostIt.Android.apk') et l'API # (un fichier nommé '?name=PostIt.Android.apk') et l'API
# Forgejo renvoie 400 "Missing 'name' parameter". # Forgejo renvoie 400 "Missing 'name' parameter".
echo "::group::Upload PostIt APK assets" echo "::group::Upload APK asset"
for MARCH in arm64 x64; do
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
-X POST \ -X POST \
-H "Authorization: token $GITHUB_TOKEN" \ -H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \ -H "Accept: application/json" \
--data-binary "@/src/_src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-$MARCH/fr.pschneider.postit-Signed.apk" \ --data-binary "@/src/_src/PostIt.Android.apk" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android-$MARCH.apk") "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk")
echo "POST asset -> HTTP $HTTP" echo "POST asset -> HTTP $HTTP"
echo "::endgroup::"
if [[ "$HTTP" != "201" ]]; then if [[ "$HTTP" != "201" ]]; then
echo "::error::Asset upload failed (HTTP $HTTP):" echo "::error::Asset upload failed (HTTP $HTTP):"
cat /tmp/asset.json cat /tmp/asset.json
exit 1 exit 1
fi fi
done
echo "::endgroup::"
echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"

View file

@ -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 <<EOF pour le body multi-lignes (pattern GitHub Actions).
{
echo "RELEASE_BODY<<EOF"
echo "$BODY"
echo "EOF"
echo "RELEASE_CHANNEL=$CHANNEL"
if [[ "$CHANNEL" == "stable" ]]; then
echo "IS_PRERELEASE=false"
else
echo "IS_PRERELEASE=true"
fi
} >> "$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/<owner>/<repo>/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 }}

View file

@ -26,7 +26,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Test - 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 # 4. Build et Push de l'image de production finale
- name: Build and push production image - name: Build and push production image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7

18
.vscode/launch.json vendored
View file

@ -5,19 +5,19 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Android Debug", "name": "Debug - Android",
"type": "mono", "type": "mono",
"preLaunchTask": "run-debug-android", "preLaunchTask": "run-debug-android",
"request": "attach", "request": "attach",
"address": "localhost", "address": "localhost",
"port": 55555 "port": 10000
}, },
{ {
"name": "Android Attach - Debug", "name": "Attach - Android",
"type": "mono", "type": "mono",
"request": "attach", "request": "attach",
"address": "localhost", "address": "localhost",
"port": 55555 "port": 10000
}, },
{ {
"name": "API", "name": "API",
@ -26,29 +26,31 @@
"projectPath": "${workspaceFolder}/src/Api/Api.csproj" "projectPath": "${workspaceFolder}/src/Api/Api.csproj"
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc.Org",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc.Blogs",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
}, },
{ {
"name": "PostIt Desktop", "name": "PostIt",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
}, },
{ {
"name": "Test PostIt.Android launch (Xamarin.UITest)", "name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"args": [], "args": [
],
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
"console": "integratedTerminal", "console": "integratedTerminal",
"stopAtEntry": false "stopAtEntry": false

View file

@ -5,8 +5,6 @@
"appsettings", "appsettings",
"asciidoctor", "asciidoctor",
"ASPNETCORE", "ASPNETCORE",
"Avalonia",
"blogspot",
"Configurabilité", "Configurabilité",
"Cratie", "Cratie",
"DESTDIR", "DESTDIR",
@ -14,11 +12,9 @@
"DOTNET", "DOTNET",
"ecdsa", "ecdsa",
"envsubst", "envsubst",
"Forgejo",
"Hsts", "Hsts",
"Newtonsoft", "Newtonsoft",
"Npgsql", "Npgsql",
"Oidc",
"PKCE", "PKCE",
"postit", "postit",
"pschneider", "pschneider",
@ -44,6 +40,5 @@
"copilotcli/gpt-5.3-codex" "copilotcli/gpt-5.3-codex"
] ]
} }
}, }
"dotnet.defaultSolution": "yavsc.sln"
} }

83
.vscode/tasks.json vendored
View file

@ -1,22 +1,5 @@
{ {
"version": "2.0.0", "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": [ "tasks": [
{ {
"label": "run-debug-android", "label": "run-debug-android",
@ -27,17 +10,19 @@
"env": { "env": {
"DOTNET_HOST_PATH": "/usr/share/dotnet", "DOTNET_HOST_PATH": "/usr/share/dotnet",
"ANDROID_HOME": "/opt/android-sdk", "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": [ "args": [
"run", "build"
"-t:run",
"-p:TargetFramework=net10.0-android", "-p:TargetFramework=net10.0-android",
"-p:Configuration=Debug", "-p:Configuration=Debug",
"-p:AndroidAttachDebugger=true", "-p:AndroidAttachDebugger=true",
"-p:AndroidSdbHostPort=55555", "-p:AndroidSdbHostPort=10000",
"-p:AndroidSdbTargetPort=55555" "-p:AndroidSdbTargetPort=10000"
] ],
"problemMatcher": "$msCompile"
}, },
{ {
"label": "build", "label": "build",
@ -47,6 +32,7 @@
"group": "build", "group": "build",
"isBuildCommand": true, "isBuildCommand": true,
"isTestCommand": false, "isTestCommand": false,
"problemMatcher": ["$msCompile"],
"isBackground": true "isBackground": true
}, },
{ {
@ -76,6 +62,59 @@
"kind": "build" "kind": "build"
}, },
"isBackground": true "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": {
}
} }
] ]
} }

View file

@ -1,68 +1,20 @@
# Changelog # 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 Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
### Fixed pour la production des paquets `.deb`.
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
## [1.0.8-rc1] - unstable ## [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 migration, reverted in this release. The publish toggle covers
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.8-rc1]: https://github.com/pazof/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.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7
[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
## [1.0.6] - stable ## [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 actual release id. Switched to `jq` for both body construction and
field extraction. 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

View file

@ -11,7 +11,7 @@
## Premier build ## Premier build
```bash ```bash
git clone https://forgejo.pschneider.fr/notazof/yavsc.git git clone https://github.com/pazof/yavsc.git
cd yavsc cd yavsc
dotnet restore dotnet restore
dotnet build dotnet build
@ -49,26 +49,6 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ». item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. - `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) ## Navigation (PostIt)
La navigation est centralisée dans La navigation est centralisée dans

View file

@ -1,6 +1,16 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<RootNamespace>Yavsc</RootNamespace> <RootNamespace>Yavsc</RootNamespace>
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn> <!--
GitVersion.MsBuild is referenced as a build-time package from
every csproj under src/. Setting UseProjectNamespaceForGitVersionInformation
here (in Directory.Build.props) means each assembly exposes a
GitVersionInformation type under its own namespace, e.g.
PostIt.GitVersionInformation, Yavsc.GitVersionInformation, so
the assembly metadata reflects the source tree it was built
from without conflicting names.
-->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.8.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />

173
Makefile
View file

@ -77,6 +77,13 @@ release:
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \ echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
exit 1; \ exit 1; \
fi 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 \ @if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \ echo "Working tree sale, refus de créer une branche release."; \
git status --short; \ git status --short; \
@ -114,4 +121,168 @@ release:
git push -u origin "$$BRANCH"; \ git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin." 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__/<rid>/` 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/<rid>/` 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

View file

@ -16,10 +16,11 @@ https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml
# Statut actuel des actions GitHub # 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) * [![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 # Documentation

View file

@ -5,29 +5,33 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <!-- Avalonia packages -->
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" /> <!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="12.1.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Android" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="12.1.1" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" /> <PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" /> <PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.100" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -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__/<rid>/` 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/<rid>/` 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

View file

@ -1,11 +1,8 @@
 using Android.App;
using Android.App;
using Android.Content; using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using AndroidX.Core.Provider; using Avalonia;
using AndroidX.Emoji2.Text;
using Avalonia.Android; using Avalonia.Android;
using PostIt.Droid.Services;
namespace PostIt.Android; namespace PostIt.Android;
@ -19,21 +16,17 @@ namespace PostIt.Android;
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>
/// The current MainActivity instance. /// Strongly-typed handle to the current MainActivity instance, set in
/// <see cref="OnCreate"/> and consumed by platform services such as
/// <see cref="Services.AndroidSystemBrowser"/> which need to launch
/// Chrome Custom Tabs.
/// </summary> /// </summary>
public static MainActivity? Current { get; private set; } public static MainActivity? Current { get; private set; }
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState) 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); base.OnCreate(savedInstanceState);
PlatformBootstrap.EnsureInitialized();
Current = this; Current = this;
} }
/// <summary> /// <summary>
@ -48,13 +41,7 @@ public class MainActivity : AvaloniaMainActivity
protected override void OnNewIntent(Intent? intent) protected override void OnNewIntent(Intent? intent)
{ {
base.OnNewIntent(intent); base.OnNewIntent(intent);
if (intent is not null) AndroidOidcCallbackSink.Handle(intent);
var url = intent?.DataString;
if (!string.IsNullOrEmpty(url) && url.StartsWith("postit://callback"))
{
OidcCallbackManager.SetResult(url);
}
} }
internal static class AndroidOidcCallbackSink internal static class AndroidOidcCallbackSink

View file

@ -12,9 +12,14 @@ namespace PostIt.Android;
/// </summary> /// </summary>
internal static class PlatformBootstrap 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 = () => Platform.CreateBrowser = () =>
{ {
var activity = MainActivity.Current; var activity = MainActivity.Current;

View file

@ -4,26 +4,29 @@
<TargetFramework>net10.0-android</TargetFramework> <TargetFramework>net10.0-android</TargetFramework>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion> <SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationId>fr.pschneider.postit</ApplicationId> <ApplicationId>fr.pschneider.PostIt</ApplicationId>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AndroidLinkMode>SdkOnly</AndroidLinkMode>
<FileVersion>1.1.0.0</FileVersion> <TrimMode>partial</TrimMode>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link> <Link>Resources\drawable\Icon.png</Link>
</AndroidResource> </AndroidResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Android" /> <PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" /> <PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Xamarin.AndroidX.Browser" /> <PackageReference Include="Xamarin.AndroidX.Browser" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Runtime" />
<PackageReference Include="Xamarin.AndroidX.Lifecycle.Common" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -2,5 +2,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon"> <application android:label="PostIt" android:icon="@drawable/Icon">
<!--
Deep-link receiver for the OIDC Authorization Code + PKCE flow.
After the user authenticates in the system browser, the OP
redirects to android://postit-signin?... and Android forwards
the Intent to the MainActivity (configured SingleTask so the
existing instance receives OnNewIntent rather than spawning a
new one).
The host value (postit-signin) MUST match the
AndroidRedirectUri constant in PostIt/Settings/Settings.cs and
the corresponding RedirectUri registered for the 'postit'
client in IdentityServer (Yavsc.Org ConfigurationDb).
-->
<activity-alias
android:name="PostIt.Android.OidcCallbackActivity"
android:targetActivity="PostIt.Android.PostItMainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="android" android:host="postit-signin" />
</intent-filter>
</activity-alias>
</application> </application>
</manifest> </manifest>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>MIIEqDCCA5CgAwIBAgIJAN5gc16AJfAsMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRAwDgYDVQQDEwdBbmRyb2lkMSEwHwYJKoZIhvcNAQkBFhJhbmRyb2lkQGFuZHJvaWQuY29tMCAXDTA4MDQxNTIyNDA0M1YYDzQyMDgxMzA0MjI0MDQzWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDURvdW50YWluIFZpZXcxEDAOBgNVBAoTB0dvb2dsZTEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEhMB8GCSqGSIb3DQEJARYSYW5kcm9pZEBhbmRyb2lkLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALBi1vF0K1vOEHG7AxneTjOHUka46MIidBqvFcO164A49iU2DkYPhUaM4H8JCdzh6N1GzM6h9o6E2V6z8+gEtdI6nqqs0EGA0G0H701bFjLp9+K/1DkMIFeD4P8J7X1/M8t4+X09X/7bQyV3w0v7q+Qh38sY8W/7K29B3f2O2sLw+uX9U8a8Tf4Xv8A==</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZgXpYOMA0GCSqGSIb3DQEBBQUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKKvSkUIXm+t9M8rXj2V</item>
</string-array>
</resources>

View file

@ -3,7 +3,6 @@ using System.Threading.Tasks;
using Android.App; using Android.App;
using AndroidX.Browser.CustomTabs; using AndroidX.Browser.CustomTabs;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using PostIt.Droid.Services;
namespace PostIt.Android.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 uri = global::Android.Net.Uri.Parse(options.StartUrl)!;
var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync();
var tabsIntent = new CustomTabsIntent.Builder() var tabsIntent = new CustomTabsIntent.Builder()
.SetShowTitle(true)! .SetShowTitle(true)!
.Build(); .Build();

View file

@ -1,21 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Droid.Services;
public static class OidcCallbackManager
{
private static TaskCompletionSource<string>? _tcs;
public static Task<string> RegisterCallback(CancellationToken cancellationToken)
{
_tcs = new TaskCompletionSource<string>();
cancellationToken.Register(() => _tcs.TrySetCanceled());
return _tcs.Task;
}
public static void SetResult(string url)
{
_tcs?.TrySetResult(url);
}
}

View file

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

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,29 @@
using PostIt.Services;
namespace PostIt.Desktop;
/// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared OIDC login path sees a working <c>IBrowser</c> — 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 <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user
/// has configured to open it.
/// </summary>
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";
}
}

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -12,6 +12,8 @@ sealed class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
PlatformBootstrap.EnsureInitialized();
// Short-circuit 2nd-instance launches (OS handing us the // Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window. // postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes // If we let Avalonia initialise, the new MainWindow flashes

View file

@ -73,7 +73,7 @@ public class AddCircleMemberDialogTests
return context; return context;
} }
/// <summary> /// <summary>
/// Mount a real <see cref="MainView"/>, build a minimal /// Mount a real <see cref="MainWindow"/>, build a minimal
/// DI graph, push <see cref="CirclesPage"/> then the /// DI graph, push <see cref="CirclesPage"/> then the
/// <see cref="AddCircleMemberDialog"/> on top of it. /// <see cref="AddCircleMemberDialog"/> on top of it.
/// Returns the stack size so the test can pin the delta. /// Returns the stack size so the test can pin the delta.
@ -98,9 +98,12 @@ public class AddCircleMemberDialogTests
services.AddTransient<AddCircleMemberDialogViewModel>(); services.AddTransient<AddCircleMemberDialogViewModel>();
var sp = services.BuildServiceProvider(); var sp = services.BuildServiceProvider();
context.Window = new MainView(); context.Window = new MainWindow();
context.App = (PostIt.App)Application.Current!; context.App = (PostIt.App)Application.Current!;
context.App.DataTemplates.Clear();
context.App.DataTemplates.Add(new ViewLocator(sp));
context.App.AttachMainWindow(context.Window); context.App.AttachMainWindow(context.Window);
context.Window.Show();
context.page = sp.GetRequiredService<CirclesPage>(); context.page = sp.GetRequiredService<CirclesPage>();
context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult(); context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult();

View file

@ -12,10 +12,9 @@ namespace PostIt.Tests;
/// Skip conditions: the package is not installed on the connected device, /// Skip conditions: the package is not installed on the connected device,
/// or no device is connected via adb. /// or no device is connected via adb.
/// </summary> /// </summary>
[Trait("Category", "Platform-Android")]
public class AndroidAppLaunchTests public class AndroidAppLaunchTests
{ {
private const string PackageName = "fr.pschneider.postit"; private const string PackageName = "fr.pschneider.PostIt";
private readonly ITestOutputHelper _output; private readonly ITestOutputHelper _output;
@ -24,8 +23,7 @@ public class AndroidAppLaunchTests
_output = output; _output = output;
} }
// TODO https://twosixtech.com/blog/integrating-docker-and-adb/ // FIXME [Fact]
[Fact]
public void PostIt_starts_and_draws_a_first_frame_on_the_emulator() public void PostIt_starts_and_draws_a_first_frame_on_the_emulator()
{ {
if (!IsPackageInstalledOnAnyDevice()) if (!IsPackageInstalledOnAnyDevice())

View file

@ -166,51 +166,4 @@ public class BlogPostAuthorDtoTests
Assert.True(root.TryGetProperty("userName", out _)); Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", 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<BlogPostDto>(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 _));
}
} }

View file

@ -99,7 +99,7 @@ public class MainPageButtonsTests
} }
/// <summary> /// <summary>
/// Mount a real <see cref="MainView"/> (as /// Mount a real <see cref="MainWindow"/> (as
/// <c>SessionStatusBannerTests</c> does), push a /// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto /// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via /// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
@ -109,12 +109,18 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real /// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against. /// <see cref="TopLevel"/> to dispatch against.
/// </summary> /// </summary>
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 page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!; 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); app.AttachMainWindow(window);
window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page); return (window, page);
} }
@ -124,7 +130,7 @@ public class MainPageButtonsTests
/// supported headless pattern (cf. CalculatorTests in the /// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count /// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta. /// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainView"/> /// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
/// itself — it is the <see cref="TopLevel"/> that owns the /// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any /// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via /// descendant TopLevel (e.g. one obtained via
@ -133,7 +139,7 @@ public class MainPageButtonsTests
/// because the descendant does not carry the /// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects. /// <c>PlatformHandle</c> the harness expects.
/// </summary> /// </summary>
private static int ClickAndCapture(MainView window, Button button) private static int ClickAndCapture(MainWindow window, Button button)
{ {
var stackBefore = window.NavRoot.NavigationStack.Count; var stackBefore = window.NavRoot.NavigationStack.Count;
button.Command?.Execute(button.CommandParameter); button.Command?.Execute(button.CommandParameter);

View file

@ -79,9 +79,9 @@ public class MainPageSaveTests
// whose Title is exactly what the user typed. The bug // whose Title is exactly what the user typed. The bug
// fails this assertion with Title == string.Empty. // fails this assertion with Title == string.Empty.
Assert.NotEmpty(recorder.Calls); Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.Calls[1]; var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method); Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blogspot", path); Assert.Equal("blog", path);
var sent = Assert.IsType<BlogPostDto>(body); var sent = Assert.IsType<BlogPostDto>(body);
Assert.Equal(typed, sent.Title); Assert.Equal(typed, sent.Title);
} }

View file

@ -9,7 +9,6 @@ using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot; using Yavsc.Blogspot;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -118,7 +117,7 @@ public class PostAclDialogTests
/// rebinding the global DI mid-test would trample the /// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on. /// Settings singleton the rest of the harness depends on.
/// </summary> /// </summary>
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 handler = new CountingHttpHandler();
var settings = new Settings(); var settings = new Settings();
@ -139,9 +138,12 @@ public class PostAclDialogTests
// CountingHttpHandler. // CountingHttpHandler.
GC.KeepAlive(sp); GC.KeepAlive(sp);
var window = new MainView(); var window = new MainWindow();
var app = (App)Application.Current!; var app = (App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window); app.AttachMainWindow(window);
window.Show();
return (window, aclClient, circleClient, handler); return (window, aclClient, circleClient, handler);
} }
@ -191,8 +193,9 @@ public class PostAclDialogTests
await Task.Delay(20); await Task.Delay(20);
} }
// Assert: one GET went out (for /circle) from LoadAsync. // Assert: exactly two GETs went out (one to /blogacl,
Assert.Equal(1, handler.RequestCount); // one to /circle), both from the LoadAsync call.
Assert.Equal(2, handler.RequestCount);
// And the VM's idempotency gate has flipped. // And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded); Assert.True(vm.Loaded);
@ -218,58 +221,7 @@ public class PostAclDialogTests
await vm.LoadAsync(); await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded. // Assert: the second call short-circuited on _loaded.
Assert.Equal(1, handler.RequestCount); Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded); 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<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
CallCount++;
if (typeof(T) == typeof(List<CircleDto>))
{
var circles = new List<CircleDto>
{
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;
}
} }

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -16,7 +16,9 @@
<PackageReference Include="Xamarin.UITest" /> <PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" /> <PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" /> <PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup> </ItemGroup>
@ -26,5 +28,6 @@
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup></ItemGroup> <ItemGroup>
</ItemGroup>
</Project> </Project>

View file

@ -55,21 +55,6 @@ public class PostItViewModelTests
Assert.Equal("Hello", posts[0].Title); 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);
}
/// <summary>Test fake that always throws if the API is invoked.</summary> /// <summary>Test fake that always throws if the API is invoked.</summary>
private sealed class ThrowingYavscApiClient : YavscApiClient private sealed class ThrowingYavscApiClient : YavscApiClient
{ {
@ -118,34 +103,4 @@ public class PostItViewModelTests
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }
} }
private sealed class RecordingPublishApi : IYavscApiClient
{
public bool LastPublishValue { get; private set; }
public HttpClient Http { get; } = new();
public Task<T> CallAsync<T>(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;
}
} }

View file

@ -9,7 +9,7 @@ namespace PostIt.Tests;
/// <summary> /// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside /// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainView"/> via the headless Avalonia /// a real <see cref="MainWindow"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>. /// platform declared in <c>TestApp.cs</c>.
/// ///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c> /// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
@ -34,10 +34,11 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree() 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(); window.Show();
var buttons = window.GetVisualDescendants() var buttons = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.ToList(); .ToList();
@ -45,7 +46,7 @@ public class SessionStatusBannerTests
// déconnecter, Se connecter, Paramètres. If any one is // déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the // missing, the user has no way to trigger the
// corresponding navigation event. // corresponding navigation event.
Assert.Equal(4, buttons.Count); Assert.Equal(3, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter"); Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter"); Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres"); Assert.Contains(buttons, b => b.Content as string == "Paramètres");
@ -54,10 +55,13 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out() public void Banner_login_button_is_visible_when_logged_out()
{ {
MainWindow window = new MainWindow(); var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
window.Show(); window.Show();
var login = window.GetVisualDescendants() var login = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se connecter"); .Single(b => b.Content as string == "Se connecter");
@ -69,13 +73,13 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out() public void Banner_logout_button_is_hidden_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
Assert.False((banner.DataContext as SessionStatusViewModel)! var logout = window.SessionBanner.GetVisualDescendants()
.IsLoggedIn); // VM default
var logout = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter"); .Single(b => b.Content as string == "Se déconnecter");
@ -85,9 +89,11 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session() public void Banner_settings_button_is_visible_regardless_of_session()
{ {
SessionStatusBanner banner = CreateBanner(); var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var settings = banner.GetVisualDescendants() var settings = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Paramètres"); .Single(b => b.Content as string == "Paramètres");
@ -97,21 +103,14 @@ public class SessionStatusBannerTests
Assert.True(settings.IsVisible); Assert.True(settings.IsVisible);
} }
private static SessionStatusBanner CreateBanner()
{
MainWindow window = new MainWindow();
window.Show();
var banner = window.MainView.SessionBanner;
var status = new SessionStatusViewModel();
banner.DataContext = status;
return banner;
}
[AvaloniaFact] [AvaloniaFact]
public void Banner_session_label_reflects_DataContext() public void Banner_session_label_reflects_DataContext()
{ {
SessionStatusBanner banner = CreateBanner(); var window = new MainWindow();
var label = banner.GetVisualDescendants() window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var label = window.SessionBanner.GetVisualDescendants()
.OfType<TextBlock>() .OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté"); .First(t => t.Text == "Déconnecté" || t.Text == "Connecté");

View file

@ -1,5 +1,3 @@
using System.Text.Json;
namespace PostIt.Tests; namespace PostIt.Tests;
public class SettingsLoadTests public class SettingsLoadTests
@ -87,7 +85,7 @@ public class SettingsLoadTests
bool flip = ((workerId + i) & 1) == 0; bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip; settings.DarkMode = flip;
settings.Authentication.RedirectUri = settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri; global::AuthenticationSettings.DefaultDesktopRedirectUri;
settings.BusinessApiUrl = flip settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/" ? "https://a.example.test/api/v1/"
@ -151,26 +149,4 @@ public class SettingsLoadTests
Assert.True(settings.Loaded); Assert.True(settings.Loaded);
} }
[Fact]
public void SearchText_is_serialized_in_settings_and_round_trips()
{
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests",
Scopes = new[] { "openid" }
}
};
settings.SearchText = "bonjour";
var json = JsonSerializer.Serialize(settings);
var roundTrip = JsonSerializer.Deserialize<PostIt.ViewModels.Settings>(json);
Assert.NotNull(roundTrip);
Assert.Equal("bonjour", roundTrip.SearchText);
}
} }

View file

@ -4,7 +4,7 @@ namespace PostIt.Tests;
internal class TestAppContext internal class TestAppContext
{ {
public MainView? Window {get; set; } public MainWindow? Window {get; set; }
public CirclesPage? page {get; set; } public CirclesPage? page {get; set; }
public AddCircleMemberDialog? dialog { get; set; } public AddCircleMemberDialog? dialog { get; set; }
public App? App { get; internal set; } public App? App { get; internal set; }

View file

@ -8,7 +8,8 @@ public class MainPageTests
[AvaloniaFact] [AvaloniaFact]
public void MainPage_Should_Load() public void MainPage_Should_Load()
{ {
var window = new MainView(); var window = new MainWindow();
window.Show();
Assert.NotNull(window); Assert.NotNull(window);
} }
} }

View file

@ -11,5 +11,6 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View file

@ -1,5 +1,5 @@
using System; using System;
using System.Threading; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
@ -16,8 +16,6 @@ namespace PostIt;
public partial class App : Application public partial class App : Application
{ {
private int _bootStarted;
/// <summary> /// <summary>
/// DI container the platform entry points hand to ViewModels so /// DI container the platform entry points hand to ViewModels so
/// they can resolve the canonical <see cref="Settings"/> singleton /// they can resolve the canonical <see cref="Settings"/> singleton
@ -30,11 +28,14 @@ public partial class App : Application
/// </summary> /// </summary>
public IServiceProvider? ServiceProvider { get; private set; } public IServiceProvider? ServiceProvider { get; private set; }
public MainView? View { get; private set; } public MainWindow? Window { get; private set; }
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
#if DEBUG
this.AttachDeveloperTools();
#endif
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
@ -46,11 +47,7 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var window = ServiceProvider.GetRequiredService<MainWindow>(); desktop.MainWindow = CreateMainWindow();
desktop.MainWindow = window;
View = window.MainView;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings); ApplyDarkMode(settings);
} }
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
@ -58,62 +55,53 @@ public partial class App : Application
singleViewFactoryApplicationLifetime.MainViewFactory = singleViewFactoryApplicationLifetime.MainViewFactory =
() => () =>
{ {
View = ServiceProvider.GetRequiredService<MainView>(); Window = CreateMainWindow();
this.ConfigureRootView(View);
ApplyDarkMode(settings); ApplyDarkMode(settings);
return View; return Window;
}; };
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{ {
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>(); singleViewPlatform.MainView = CreateMainWindow();
ConfigureRootView(View);
ApplyDarkMode(settings); ApplyDarkMode(settings);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private void ConfigureRootView(MainView rootView)
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
private MainWindow CreateMainWindow()
{
Window = new MainWindow();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () => sessionStatus.LogoutCompleted += () =>
{ {
// Remplacer Window.NavRoot par rootView.NavRoot Window.NavRoot.PopToRootAsync();
rootView.NavRoot.PopToRootAsync();
}; };
sessionStatus.LoginSucceeded += async () => sessionStatus.LoginSucceeded += () =>
{ {
await PushMainPageAsync(); PushMainPageAsync().Wait();
}; };
rootView.SessionBanner.DataContext = sessionStatus; var homeVm = ServiceProvider!.GetRequiredService<HomePageViewModel>();
}
private async Task BootOnceAsync() this.PushPageAsync(homeVm).Wait();
{ Window.SessionBanner.DataContext = sessionStatus;
if (Interlocked.Exchange(ref _bootStarted, 1) == 1) return Window;
{
return;
}
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
await BootAsync(this.ServiceProvider!, api);
} }
/// <summary> /// <summary>
/// Test-only hook: bind a concrete <see cref="MainView"/> so /// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can /// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless /// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap. /// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary> /// </summary>
internal void AttachMainWindow(MainView mainView) internal void AttachMainWindow(MainWindow mainWindow)
{ {
View = mainView ?? throw new ArgumentNullException(nameof(mainView)); Window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
} }
private static void ApplyDarkMode(Settings settings) private static void ApplyDarkMode(Settings settings)
@ -137,9 +125,6 @@ private void ConfigureRootView(MainView rootView)
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePage);
if (!refreshed) return; if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true); await PushMainPageAsync().ConfigureAwait(true);
@ -157,7 +142,6 @@ private void ConfigureRootView(MainView rootView)
{ {
var app = (App)Current!; var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>(); var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm); await app.PushPageAsync(mainVm);
} }
@ -195,6 +179,6 @@ private void ConfigureRootView(MainView rootView)
internal async Task GoBackAsync() internal async Task GoBackAsync()
{ {
await View!.NavRoot.PopAsync(); await Window!.NavRoot.PopAsync();
} }
} }

View file

@ -23,13 +23,11 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
// Vues // Vues
services.AddSingleton<MainView>(); services.AddTransient<MainPage>();
services.AddSingleton<MainPage>();
services.AddSingleton<MainWindow>();
// SettingsPage is a singleton: there must be one and only one // SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app. // instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the // This guarantees that (a) the bindings always reflect the
@ -42,21 +40,9 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh // the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits. // SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddSingleton<HomePage>(); services.AddTransient<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddTransient<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddTransient<CirclesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
// Dialogs (modal-light pages): the ViewLocator resolves // Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or // them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync. // AddCircleMemberDialogViewModel via App.PushPageAsync.
@ -65,13 +51,27 @@ public static class ServiceCollectionHelpers
// here — the parametrised ctors stay for direct test wiring. // here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>(); services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>(); services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of // Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation. // the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api }; var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh(); sessionStatus.Refresh();
services.AddSingleton(sessionStatus); services.AddSingleton(sessionStatus);
services.AddSingleton<SessionStatusBanner>(); services.AddTransient<SessionStatusBanner>();
services.AddSingleton<MainViewModel>();
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
} }

View file

@ -10,8 +10,7 @@ public static class ViewModelBaseHelpers
{ {
public static async Task PushPageAsync(this App app, ViewModelBase vm) public static async Task PushPageAsync(this App app, ViewModelBase vm)
{ {
var window = app.View; if (app.Window is null)
if (window is null)
{ {
throw new InvalidOperationException("MainWindow is not initialized yet."); throw new InvalidOperationException("MainWindow is not initialized yet.");
} }
@ -40,12 +39,12 @@ public static class ViewModelBaseHelpers
page.DataContext = vm; page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage). // Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack; var stack = app.Window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{ {
return; return;
} }
await window.NavRoot.PushAsync(page); await app.Window.NavRoot.PushAsync(page);
} }
} }

View file

@ -1,16 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="postit-settings.json"> <Content Include="postit-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -30,9 +28,11 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" /> <ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />

View file

@ -0,0 +1,36 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Desktop stub for <see cref="IContactService"/>.
///
/// <para>The desktop has no equivalent of the mobile address
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
/// box). Rather than synthesise a list from a different
/// source, this provider returns an empty list and lets the
/// UI render an honest "no local contacts on this platform"
/// message.</para>
///
/// <para>If desktop users want to invite people who aren't
/// Yavsc members, that flow goes through a separate path
/// (manual email entry + invitation endpoint) — not through
/// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
}
#endif

View file

@ -21,14 +21,14 @@ public static class Platform
/// override this property at startup (e.g. PostIt.Android sets /// override this property at startup (e.g. PostIt.Android sets
/// it to <c>android://postit-signin</c>). /// it to <c>android://postit-signin</c>).
/// </summary> /// </summary>
public const string RedirectUri = "postit://callback"; public static string DefaultRedirectUri { get; set; } = "postit://callback";
/// <summary> /// <summary>
/// Scheme prefix the <see cref="CustomSchemeBrowser"/> matches /// Scheme prefix the <see cref="CustomSchemeBrowser"/> matches
/// against <c>BrowserOptions.EndUrl</c>. Overridable for apps /// against <c>BrowserOptions.EndUrl</c>. Overridable for apps
/// that want to register their own scheme. /// that want to register their own scheme.
/// </summary> /// </summary>
public const string CustomScheme = "postit"; public static string CustomScheme { get; set; } = "postit";
/// <summary> /// <summary>
/// Constructs a fresh <see cref="IBrowser"/> for the running platform. /// Constructs a fresh <see cref="IBrowser"/> for the running platform.

View file

@ -0,0 +1,72 @@
using System;
using System.Threading.Tasks;
using Avalonia.Threading;
namespace PostIt.Services;
/// <summary>
/// Tiny marshalling helper around <see cref="Dispatcher.UIThread"/> 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
/// <see cref="ObservableObject"/>-derived types (Settings, the various
/// ViewModels) can fire <c>PropertyChanged</c> safely from background
/// work — which is exactly the cross-thread case that previously blew
/// up inside <c>DataValidationErrors.SetErrors</c> 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. <c>Settings.Load</c> continuation paths).
/// </summary>
public static class UiDispatcher
{
/// <summary>
/// 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 <see cref="InvokeIfNeeded"/>.
/// </summary>
public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess();
/// <summary>
/// Run <paramref name="action"/> 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.
/// </summary>
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 */ }
}
/// <summary>
/// Fire-and-forget variant: schedules <paramref name="action"/> 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.
/// </summary>
public static void Post(Action action)
{
if (action is null) return;
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone */ }
}
/// <summary>
/// Awaitable variant. Useful inside <c>async</c> ViewModel methods
/// that must touch bindings only after the dispatcher has processed
/// a queued update (e.g. "load file then refresh observable state").
/// </summary>
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();
}
}

View file

@ -10,7 +10,7 @@ public partial class AuthenticationSettings : ObservableObject
/// hand-off in <see cref="PostIt.Services.SingleInstance"/> /// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this. /// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary> /// </summary>
public const string DesktopRedirectUri = "postit://callback"; public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary> /// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter /// Redirect URI used by the Android app. The corresponding IntentFilter
@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject
/// </summary> /// </summary>
public const string AndroidRedirectUri = "android://postit-signin"; 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] [ObservableProperty]
public partial string Authority { get; set; } public partial string Authority { get; set; }
@ -34,19 +34,15 @@ public partial class AuthenticationSettings : ObservableObject
[ObservableProperty] [ObservableProperty]
public partial string[] Scopes { get; set; } public partial string[] Scopes { get; set; }
/// <summary> /// <summary>
/// OAuth redirect URI. Defaults to <see cref="DesktopRedirectUri"/> /// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop /// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to /// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>. /// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
public partial string RedirectUri { get; set; } public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
#if ANDROID
= AndroidRedirectUri;
#else
= DesktopRedirectUri;
#endif
/// <summary> /// <summary>
/// Space-separated view of <see cref="Scopes"/>. Exists for the /// Space-separated view of <see cref="Scopes"/>. Exists for the

View file

@ -16,6 +16,12 @@ namespace PostIt;
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate public class ViewLocator : IDataTemplate
{ {
private readonly IServiceProvider _services;
public ViewLocator(IServiceProvider services)
{
_services = services;
}
public Control Build(object? data) public Control Build(object? data)
{ {
@ -32,17 +38,15 @@ public class ViewLocator : IDataTemplate
private Control BuildCore(object? data) private Control BuildCore(object? data)
{ {
var app = App.Current as App;
var services = app!.ServiceProvider!;
return data switch return data switch
{ {
MainViewModel => services.GetRequiredService<MainPage>(), MainViewModel => _services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(), AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(), CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(), PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase
/// mutable field. Toggling is its own action.</summary> /// mutable field. Toggling is its own action.</summary>
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } public partial bool DraftIsPublished { get; set; }
public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
[ObservableProperty] [ObservableProperty]
@ -72,218 +72,6 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial Settings Settings { get; private set; } 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();
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
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);
}
/// <summary>
/// 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
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[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<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
/// <summary> /// <summary>
/// API surface that hits the Yavsc.Blogs deployment at /// API surface that hits the Yavsc.Blogs deployment at
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by /// <see cref="Settings.ApiUrl"/>. Owned and constructed by
@ -342,18 +130,17 @@ public partial class MainViewModel : ViewModelBase
private void Init(Settings? settings) private void Init(Settings? settings)
{ {
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPostDto>(); Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>(); FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
StatusMessage = "Ready"; StatusMessage = "Ready";
Settings = settings ?? new Settings(); Settings = settings ?? new Settings();
SearchText = Settings.SearchText;
WindowTitle = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
IsLoaded = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor // and we use it as-is. Test path: tests call this constructor
// without a Settings argument; we fall back to a fresh // 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 // (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous. // instance is now merely wasteful, not dangerous.
Settings.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(Settings.SearchText))
{
SearchText = Settings.SearchText;
ApplyFilter();
}
};
} }
/// <summary>Save is enabled as soon as the user has typed /// <summary>Save is enabled as soon as the user has typed
@ -401,14 +179,7 @@ public partial class MainViewModel : ViewModelBase
Init(settings); Init(settings);
} }
partial void OnSearchTextChanged(string value) partial void OnSearchTextChanged(string value) => ApplyFilter();
{
if (Settings is not null && Settings.SearchText != value)
{
Settings.SearchText = value;
}
ApplyFilter();
}
partial void OnSelectedPostChanged(BlogPostDto? value) partial void OnSelectedPostChanged(BlogPostDto? value)
{ {
@ -435,6 +206,170 @@ public partial class MainViewModel : ViewModelBase
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(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();
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[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.";
});
}
/// <summary>
/// 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
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[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<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()
{ {
@ -491,18 +426,28 @@ public partial class MainViewModel : ViewModelBase
private void UpdateCommandStates() private void UpdateCommandStates()
{ {
RefreshCommand.NotifyCanExecuteChanged(); LoadPostsCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged(); SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
} }
internal async Task InitializeAsync()
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAcl()
{ {
if (!IsLoaded) if (SelectedPost is null)
{ {
await RefreshAsync(); StatusMessage = "Select an existing post before managing ACL.";
IsLoaded = true; return;
} }
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
} }
} }

View file

@ -1,8 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@ -10,17 +8,9 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using System.Net.Http;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public sealed class PostAclEntry
{
public long CircleId { get; init; }
public string CircleName { get; init; } = string.Empty;
}
/// <summary> /// <summary>
/// View model for the "Gérer l'ACL" modal of a single blog post. /// 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(); MyCircles { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<PostAclEntry> public partial ObservableCollection<PostAccessControlRulePayload>
AclEntries { get; set; } = new(); AclEntries { get; set; } = new();
[ObservableProperty] [ObservableProperty]
@ -87,9 +77,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
Post = post ?? throw new ArgumentNullException(nameof(post)); Post = post ?? throw new ArgumentNullException(nameof(post));
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
AclEntries = new ObservableCollection<PostAclEntry>(post.GetACL().Select(a => ToAclEntry(a.CircleId)));
SelectedCircleToAdd = null;
} }
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
@ -103,17 +90,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
// Load circles for the picker. ACL entries come from the // Load circles and ACL entries in parallel — both are
// BlogPostDto detail payload (source of truth for initial state). // independent reads on the same host. The caller's uid
// is implicit in both endpoints.
var circlesTask = _circleClient.GetMyCirclesAsync(); var circlesTask = _circleClient.GetMyCirclesAsync();
await Task.WhenAll(circlesTask); var aclTask = _aclClient.GetMyAclAsync();
await Task.WhenAll(circlesTask, aclTask);
var circles = circlesTask.Result ?? new List<CircleDto>(); var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles); MyCircles = new ObservableCollection<CircleDto>(circles);
// Resolve labels now that circles are available.
AclEntries = new ObservableCollection<PostAclEntry>(AclEntries.Select(a => ToAclEntry(a.CircleId)));
StatusMessage = $"{AclEntries.Count} autorisation(s)"; StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true; _loaded = true;
@ -140,20 +126,14 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id)) var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
return;
}
var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload
{ {
CircleId = SelectedCircleToAdd.Id, CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id BlogPostId = Post.Id
}); });
if (created is not null) if (created is not null)
{ {
AclEntries.Add(ToAclEntry(created.CircleId)); AclEntries.Add(created);
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
} }
else else
@ -161,13 +141,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
StatusMessage = "Autorisation refusée par le serveur"; 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) catch (Exception ex)
{ {
StatusMessage = $"Erreur: {ex.Message}"; StatusMessage = $"Erreur: {ex.Message}";
@ -179,16 +152,14 @@ public partial class PostAclDialogViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async Task RevokeAsync(PostAclEntry? acl) public async Task RevokeAsync(PostAccessControlRulePayload? acl)
{ {
if (acl is null) return; if (acl is null) return;
IsBusy = true; IsBusy = true;
try try
{ {
await _aclClient.RevokeAsync(acl.CircleId); await _aclClient.RevokeAsync(acl.CircleId);
var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId); AclEntries.Remove(acl);
if (existing is not null)
AclEntries.Remove(existing);
StatusMessage = "Autorisation révoquée"; StatusMessage = "Autorisation révoquée";
} }
catch (Exception ex) catch (Exception ex)
@ -200,26 +171,4 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = false; IsBusy = false;
} }
} }
private async Task ReloadAclEntriesFromServerAsync()
{
var allAcl = await _aclClient.GetMyAclAsync();
var currentPostAcl = (allAcl ?? new List<PostAccessControlRulePayload>())
.Where(a => a.BlogPostId == Post.Id)
.Select(a => ToAclEntry(a.CircleId))
.GroupBy(a => a.CircleId)
.Select(g => g.First())
.ToList();
AclEntries = new ObservableCollection<PostAclEntry>(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
};
}
} }

View file

@ -2,11 +2,13 @@ using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")] [assembly: InternalsVisibleTo("PostIt.Tests")]
@ -16,6 +18,37 @@ public partial class Settings : ViewModelBase
{ {
const string SettingsFileName = "postit-settings.json"; const string SettingsFileName = "postit-settings.json";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
/// at application boot by <see cref="App.OnFrameworkInitializationCompleted"/>
/// through <see cref="BindToServiceProvider"/>. The hybrid pattern:
/// <list type="bullet">
/// <item><description>The static <c>Current</c> reference gives
/// ViewModels a non-DI way to reach the same instance (and lets
/// the framework bindings push notifications through one stable
/// <see cref="ObservableObject"/>).</description></item>
/// <item><description>Tests that want to exercise a clean
/// instance still call <c>new Settings()</c>; <c>Current</c>
/// stays null in those contexts because <see cref="BindToServiceProvider"/>
/// is never invoked.</description></item>
/// <item><description>Reads (<see cref="GetCurrent"/>) are
/// thread-safe and never allocate; mutations always go through
/// the DI-resolved singleton so two threads cannot each register
/// a different "current" Settings.</description></item>
/// </list>
/// </summary>
private static Settings? s_current;
[ObservableProperty] [ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new(); public partial AuthenticationSettings Authentication { get; set; } = new();
@ -28,9 +61,6 @@ public partial class Settings : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/"; public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Catch top-level mutations: the four ObservableProperty /// Catch top-level mutations: the four ObservableProperty
/// setters above all funnel through here, and we flip /// setters above all funnel through here, and we flip
@ -46,7 +76,6 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty(); partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary> /// <summary>
/// Authentication can be reassigned wholesale by /// Authentication can be reassigned wholesale by
@ -299,7 +328,6 @@ public partial class Settings : ViewModelBase
{ {
this.Authentication = settings.Authentication; this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode; this.DarkMode = settings.DarkMode;
this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null)) if (!(settings.Authentication is null))
{ {
this.Authentication = new AuthenticationSettings(); this.Authentication = new AuthenticationSettings();
@ -308,7 +336,7 @@ public partial class Settings : ViewModelBase
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ? this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId; AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ? 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) if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
{ {
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes; settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
@ -349,11 +377,10 @@ public partial class Settings : ViewModelBase
{ {
Authority = AuthenticationSettings.DefaultAuthority, Authority = AuthenticationSettings.DefaultAuthority,
ClientId = AuthenticationSettings.DefaultClientId, ClientId = AuthenticationSettings.DefaultClientId,
RedirectUri = AuthenticationSettings.DesktopRedirectUri, RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri,
Scopes = AuthenticationSettings.DefaultScopes Scopes = AuthenticationSettings.DefaultScopes
}; };
this.DarkMode = false; this.DarkMode = false;
this.SearchText = string.Empty;
} }
/// <summary> /// <summary>

View file

@ -29,15 +29,15 @@
VerticalAlignment="Top"> VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding RefreshAsync}" Content="Refresh" /> <Button Command="{Binding LoadPosts}" Content="Load posts" />
<Button Command="{Binding SearchAsync}" Content="Filter" /> <Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding SaveAsync}" Content="Save" /> <Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding DeleteAsync}" Content="Delete" /> <Button Command="{Binding Delete}" Content="Delete" />
<Button x:Name="ManageAclButton" <Button x:Name="ManageAclButton"
Command="{Binding ManageAclAsync}" Command="{Binding ManageAcl}"
Content="ACL" /> Content="ACL" />
<Button x:Name="OpenCirclesButton" <Button x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}" Command="{Binding OpenCircles}"
Content="Mes cercles" /> Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to <!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires DraftIsPublished. Clicking it fires
@ -49,7 +49,7 @@
and the buffer in sync. --> and the buffer in sync. -->
<CheckBox Content="Publié" <CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}" IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishAsync}" Command="{Binding TogglePublishCommand}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- <!--
DEV ONLY: temporary shortcut to open the signature DEV ONLY: temporary shortcut to open the signature
@ -59,7 +59,7 @@
MainPage.axaml.cs once the SignalR handler lands. MainPage.axaml.cs once the SignalR handler lands.
--> -->
<Button x:Name="OpenSignatureDevButton" <Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDevAsync}" Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature" Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" /> ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel> </StackPanel>
@ -97,15 +97,16 @@
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" /> <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" /> <TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Grid.Row="2" PlaceholderText="Write something here !" <AvaloniaEdit:TextEditor Grid.Row="2"
Text="{Binding DraftArticle, Mode=TwoWay}" views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
MinHeight="320" MinHeight="320"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch"
</TextBox> VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" /> <TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View file

@ -1,6 +1,4 @@
using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace PostIt.Views; namespace PostIt.Views;
@ -10,16 +8,4 @@ public partial class MainPage : ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
} }

View file

@ -1,22 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainView"
x:DataType="vm:MainViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainViewModel />
</Design.DataContext>
<DockPanel LastChildFill="True">
<views:SessionStatusBanner
x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
</UserControl>

View file

@ -1,24 +0,0 @@
using System;
using Avalonia.Controls;
namespace PostIt.Views;
public partial class MainView : UserControl
{
public MainView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
}

View file

@ -8,5 +8,10 @@
x:Class="PostIt.Views.MainWindow" x:Class="PostIt.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico" Icon="/Assets/avalonia-logo.ico"
Title="PostIt" > Title="PostIt" >
<views:MainView x:Name="MainView" />
<DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
</Window> </Window>

View file

@ -4,6 +4,7 @@
x:Class="PostIt.Views.PostAclDialog" x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos" xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
x:DataType="vm:PostAclDialogViewModel" x:DataType="vm:PostAclDialogViewModel"
> >
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12"> <Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
@ -31,10 +32,10 @@
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}"> ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:PostAclEntry"> <DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleName}" <TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/> FontWeight="Bold"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Révoquer" <Button Grid.Column="1" Content="Révoquer"

View file

@ -0,0 +1,51 @@
using Avalonia;
using Avalonia.Data;
using AvaloniaEdit;
namespace PostIt.Views;
public sealed class TextEditorBinding
{
public static readonly AttachedProperty<string?> TextProperty =
AvaloniaProperty.RegisterAttached<TextEditorBinding, TextEditor, string?>(
"Text",
defaultBindingMode: BindingMode.TwoWay);
private static readonly AttachedProperty<bool> IsTextChangeSubscribedProperty =
AvaloniaProperty.RegisterAttached<TextEditorBinding, TextEditor, bool>("IsTextChangeSubscribed");
static TextEditorBinding()
{
TextProperty.Changed.AddClassHandler<TextEditor>((editor, args) =>
{
EnsureTextChangeSubscribed(editor);
var text = args.GetNewValue<string?>() ?? string.Empty;
if (editor.Text != text)
{
editor.Text = text;
}
});
}
public static string? GetText(TextEditor editor) => editor.GetValue(TextProperty);
public static void SetText(TextEditor editor, string? value) => editor.SetValue(TextProperty, value);
private static void EnsureTextChangeSubscribed(TextEditor editor)
{
if (editor.GetValue(IsTextChangeSubscribedProperty))
{
return;
}
editor.SetValue(IsTextChangeSubscribedProperty, true);
editor.TextChanged += (_, _) =>
{
if (editor.Text != GetText(editor))
{
SetText(editor, editor.Text);
}
};
}
}

View file

@ -13,7 +13,7 @@ namespace Yavsc.ViewModels.Account
[Required()] [Required()]
[StringLength( maximumLength:102, MinimumLength = 5)] [StringLength( maximumLength:102, MinimumLength = 5)]
[EmailAddress(ErrorMessage = "L'adresse e-mail n'est pas valide.")] // [EmailAddress]
[Display(Name = "Email", Description = "E-Mail")] [Display(Name = "Email", Description = "E-Mail")]
public string Email { get; set; } public string Email { get; set; }

View file

@ -1,5 +1,4 @@
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.Text.Json.Serialization;
namespace Yavsc.Blogspot; namespace Yavsc.Blogspot;
@ -36,26 +35,11 @@ public class BlogPostDto : IBlogPost
return true; return true;
} }
public ICollection<CircleAuthorization> ACL = new List<CircleAuthorization>(); private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
/// <summary>
/// Wire-only ACL bridge for System.Text.Json: accepts the
/// <c>acl</c>/<c>ACL</c> payload from GET detail responses,
/// but is never emitted on POST/PUT from the client.
/// </summary>
[JsonPropertyName("acl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public List<CircleAuthorization>? WireAcl
{
get => null;
set => ACL = value ?? new List<CircleAuthorization>();
}
public string[] Tags { get; set; } public string[] Tags { get; set; }
ICollection<CircleAuthorization> ICircleAuthorized.ACL => this.ACL;
public string[] GetTags() => Tags; public string[] GetTags() => Tags;
public CircleAuthorization[] GetACL() => ACL.ToArray(); public ICircleAuthorization[] GetACL() => ACL.ToArray();
} }

View file

@ -3,7 +3,8 @@ using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot; namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : CircleAuthorization public class PostAccessControlRulePayload : ICircleAuthorization
{ {
public long CircleId { get; set; }
public long BlogPostId { get; set; } public long BlogPostId { get; set; }
} }

View file

@ -7,13 +7,6 @@ namespace Yavsc
{ {
public const string APIPrefix = "api/v1"; public const string APIPrefix = "api/v1";
public const string BlogSpotPath = "blogspot";
public const string BlogAclPath = "blogacl";
public const string BlogTagPath = "blogtag";
public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments";
public static readonly Scope[] SiteScopes = { public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" }, new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"}, new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -11,7 +11,7 @@ namespace Yavsc.Abstract.Identity.Security;
/// UI already has the post, and the circles are looked up by id /// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para> /// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary> /// </summary>
public class CircleAuthorization public sealed class CircleAuthorization : ICircleAuthorization
{ {
public long CircleId { get; set; } public long CircleId { get; set; }
} }

View file

@ -0,0 +1,8 @@
namespace Yavsc.Abstract.Identity.Security
{
public interface ICircleAuthorization
{
long CircleId { get; set; }
}
}

View file

@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security
bool AuthorizeCircle(long circleId); bool AuthorizeCircle(long circleId);
ICollection<CircleAuthorization> ACL { get; } //ICircleAuthorization [] GetACL(); ICircleAuthorization [] GetACL();
} }
} }

View file

@ -5,13 +5,16 @@
<Description> A shared model for a little client/server app, dealing about establishing some contract, between some human client and provider. <Description> A shared model for a little client/server app, dealing about establishing some contract, between some human client and provider.
</Description> </Description>
<RootNamespace>Yavsc.Abstract</RootNamespace> <RootNamespace>Yavsc.Abstract</RootNamespace>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library> <Library>true</Library>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -33,7 +33,7 @@ namespace Yavsc.Api.Client;
/// </summary> /// </summary>
public sealed class BlogApiClient public sealed class BlogApiClient
{ {
private const string DefaultPathPrefix = "blogspot"; private const string DefaultPathPrefix = "blog";
private readonly IYavscApiClient _api; private readonly IYavscApiClient _api;
private readonly Uri _baseAddress; private readonly Uri _baseAddress;

View file

@ -17,9 +17,12 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -7,11 +7,14 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" /> <ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -1,12 +1,10 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access; using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using static Yavsc.Constants; using static Yavsc.Constants;
@ -40,16 +38,15 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture) public BlogAclApiTests(BlogsWebServerFixture fixture)
{ {
_fixture = fixture; _fixture = fixture;
} }
private string BlogUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}";
private string BlogAclUrl() private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}"; => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
/// <summary>Delete any ACL rows tied to the fixture's seeded /// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store /// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
@ -123,7 +120,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// owned by the caller. We seed the same shape pre-POST so the // owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end. // test reproduces the prod scenario end-to-end.
CleanupAcl(); CleanupAcl();
using var http = NewClient(_fixture.DefaultUserLogin); using var http = NewClient("alice");
var payload = new PostAccessControlRulePayload var payload = new PostAccessControlRulePayload
{ {
@ -131,8 +128,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
BlogPostId = _fixture.PostId BlogPostId = _fixture.PostId
}; };
var response = await http.PostAsJsonAsync( var response = await http.PostAsJsonAsync(BlogAclUrl(), payload,
BlogAclUrl(), payload,
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
@ -182,7 +178,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
[MemberData(nameof(BlogAclPayloadsForNever500))] [MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload) 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( var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload, BlogAclUrl(), payload,
@ -220,162 +216,4 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
); );
} }
[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<CircleAuthorizationToBlogPost>(
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<BlogPost>(
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<BlogPost>(
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());
}
} }

View file

@ -8,13 +8,11 @@ using Microsoft.IdentityModel.Tokens;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
[Collection("JwtClaimMapping")] [Collection("JwtClaimMapping")]
public sealed class BlogApiMappedClaimsTests : public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWebServerFixture>
IClassFixture<MappedClaimsBlogsWebServerFixture>
{ {
private readonly MappedClaimsBlogsWebServerFixture _fixture; private readonly MappedClaimsBlogsWebServerFixture _fixture;
@ -81,10 +79,7 @@ IClassFixture<MappedClaimsBlogsWebServerFixture>
DateModified = DateTime.UtcNow DateModified = DateTime.UtcNow
}; };
var response = await http.PostAsJsonAsync( var response = await http.PostAsJsonAsync("/api/v1/blog", draft, TestContext.Current.CancellationToken);
_fixture.BlogSpotUrl(),
draft,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken); var created = await response.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
@ -98,7 +93,7 @@ IClassFixture<MappedClaimsBlogsWebServerFixture>
ResetDatabase(); ResetDatabase();
using var http = NewClient(subject: "mapped-owner"); 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, Id = 0,
Title = "Billet à modifier", Title = "Billet à modifier",
@ -112,7 +107,7 @@ IClassFixture<MappedClaimsBlogsWebServerFixture>
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken); var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>(TestContext.Current.CancellationToken);
Assert.NotNull(created); 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, Id = created.Id,
Title = "Billet modifié", Title = "Billet modifié",
@ -131,7 +126,7 @@ IClassFixture<MappedClaimsBlogsWebServerFixture>
ResetDatabase(); ResetDatabase();
using var ownerHttp = NewClient(subject: "mapped-owner"); 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, Id = 0,
Title = "Billet protégé", Title = "Billet protégé",
@ -146,7 +141,7 @@ IClassFixture<MappedClaimsBlogsWebServerFixture>
Assert.NotNull(created); Assert.NotNull(created);
using var otherHttp = NewClient(subject: "mapped-other"); 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, Id = created.Id,
Title = "Tentative de modification", Title = "Tentative de modification",

View file

@ -2,11 +2,11 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
@ -31,6 +31,18 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
_fixture = fixture; _fixture = fixture;
} }
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Reset the database and seed the /// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required /// <c>tester</c> <see cref="ApplicationUser"/> row. Required
@ -43,10 +55,18 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
/// at <c>SaveChanges</c> and the controller returns 500.</summary> /// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser() private void ResetAndSeedDefaultUser()
{ {
_fixture.ResetDatabase(); ResetDatabase();
_fixture.SeedUser("tester"); _fixture.SeedUser("tester");
} }
/// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first
/// https URL and append the controller route
/// (<c>/api/v1/blog</c>, matching the production
/// <c>[Route(APIPrefix + "/blog")]</c>).</summary>
private string BlogsUrl =>
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
/// <summary>Build an authenticated client: a real /// <summary>Build an authenticated client: a real
/// <c>Authorization: Bearer &lt;jwt&gt;</c> header where the JWT /// <c>Authorization: Bearer &lt;jwt&gt;</c> header where the JWT
@ -91,11 +111,10 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
{ {
_fixture.ResetDatabase(); ResetDatabase();
using var http = NewClient(); using var http = NewClient();
var response = await http.GetAsync( var response = await http.GetAsync("/api/v1/blog",
_fixture.BlogSpotUrl(),
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@ -128,7 +147,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -141,7 +160,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
Assert.Equal(draft.Title, created.Title); Assert.Equal(draft.Title, created.Title);
// The list should now contain exactly one entry. // 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
@ -169,7 +188,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -179,7 +198,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
Assert.NotNull(created); Assert.NotNull(created);
Assert.Equal("tester", created!.AuthorId); Assert.Equal("tester", created!.AuthorId);
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), var listResponse = await http.GetAsync("/api/v1/blog",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
@ -207,7 +226,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -247,7 +266,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided() public async Task GetBlog_returns_401_when_no_token_is_provided()
{ {
_fixture.ResetDatabase(); ResetDatabase();
using var http = NewAnonymousClient(); using var http = NewAnonymousClient();
// No Authorization header → the JwtBearer middleware // No Authorization header → the JwtBearer middleware
@ -256,7 +275,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// the framework returns 401. This is the proof that the // the framework returns 401. This is the proof that the
// production policy is wired in the test host and not // production policy is wired in the test host and not
// short-circuited by a test-only auth bypass. // 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
} }
@ -284,7 +303,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateCreated = DateTime.UtcNow, DateCreated = DateTime.UtcNow,
DateModified = 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
@ -303,13 +322,13 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateCreated = created.DateCreated, DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow DateModified = DateTime.UtcNow
}; };
var putResponse = await http.PutAsJsonAsync(_fixture.BlogSpotUrl()+$"/{created.Id}", var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}",
update, update,
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
// The list should now reflect the new title. // 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); TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse( using var doc = JsonDocument.Parse(
@ -337,19 +356,19 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateCreated = DateTime.UtcNow, DateCreated = DateTime.UtcNow,
DateModified = 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); TestContext.Current.CancellationToken);
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>( var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
TestContext.Current.CancellationToken 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 TestContext.Current.CancellationToken
); );
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
// The list should now be empty. // The list should now be empty.
var listResponse = await http.GetAsync(_fixture.BlogSpotUrl(), var listResponse = await http.GetAsync("/api/v1/blog",
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
String response = await listResponse.Content.ReadAsStringAsync( String response = await listResponse.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken TestContext.Current.CancellationToken
@ -392,7 +411,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow DateModified = DateTime.UtcNow
}; };
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, var response = await http.PostAsJsonAsync("/api/v1/blog", draft,
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
// Dump the body on failure so the test name + the response // Dump the body on failure so the test name + the response
@ -424,7 +443,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// behaviour so a future change that, say, makes Title // behaviour so a future change that, say, makes Title
// nullable in the model or drops [Required], triggers a // nullable in the model or drops [Required], triggers a
// conscious update of the test (and probably of the VM). // conscious update of the test (and probably of the VM).
_fixture.ResetDatabase(); ResetDatabase();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
var draft = new BlogPost var draft = new BlogPost
@ -437,7 +456,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow DateModified = DateTime.UtcNow
}; };
var response = await http.PostAsJsonAsync(_fixture.BlogSpotUrl(), draft, var response = await http.PostAsJsonAsync("/api/v1/blog", draft,
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
if (response.StatusCode != HttpStatusCode.BadRequest) if (response.StatusCode != HttpStatusCode.BadRequest)

View file

@ -10,7 +10,7 @@ using Yavsc.Models.Blog;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
/// <summary> /// <summary>
@ -61,7 +61,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
public long CircleId { get; private set; } public long CircleId { get; private set; }
public long PostId { get; private set; } public long PostId { get; private set; }
public string DefaultUserLogin { get => "alice"; }
// A single SqliteConnection held open at the static level, // A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
@ -170,8 +169,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// PermissionHandler ownership check sees a null // PermissionHandler ownership check sees a null
// user id and rejects every PUT. // user id and rejects every PUT.
options.MapInboundClaims = false; options.MapInboundClaims = false;
options.TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
= new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer, ValidIssuer = TestTokenIssuer.Issuer,
@ -265,27 +263,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
await Task.CompletedTask; await Task.CompletedTask;
return app; return app;
} }
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
public void ResetDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
public void CleanupAcl()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == CircleId
&& a.BlogPostId == PostId)
.ExecuteDelete();
}
public override void Dispose() public override void Dispose()
{ {
@ -333,8 +310,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <param name="configure">Optional hook to fill in fields /// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c> /// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param> /// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
Action<ApplicationUser>? configure = null)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -375,31 +351,20 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <summary>Create a circle owned by <paramref name="ownerId"/> /// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned /// directly in the SQLite store and return its server-assigned
/// id.</summary> /// id.</summary>
public long SeedCircle(string ownerId, string name, bool isPublic = false, private long SeedCircle(string ownerId, string name, bool isPublic = false)
ICollection<String> members = null
)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic }; var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
db.Circle.Add(circle); db.Circle.Add(circle);
db.SaveChanges(); 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; return circle.Id;
} }
/// <summary>Create a blog post owned by <paramref name="authorId"/> /// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned /// directly in the SQLite store and return its server-assigned
/// id.</summary> /// id.</summary>
public long SeedBlogPost(string authorId, string title) private long SeedBlogPost(string authorId, string title)
{ {
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -415,5 +380,4 @@ public sealed class BlogsWebServerFixture : WebHostFixture
db.SaveChanges(); db.SaveChanges();
return post.Id; return post.Id;
} }
} }

View file

@ -21,11 +21,11 @@ namespace Yavsc.Blogs.Tests;
/// This is the closest in-process reproduction of the production /// This is the closest in-process reproduction of the production
/// authentication surface for the blog API. /// authentication surface for the blog API.
/// </summary> /// </summary>
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFixture public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
{ {
private readonly InMemoryDatabaseRoot _inMemoryRoot = new(); private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
private readonly Dictionary<string, string> _savedInboundMap; private readonly Dictionary<string, string> _savedInboundMap;
private WebApplication? _app = null; private readonly WebApplication _app;
public MappedClaimsBlogsWebServerFixture() public MappedClaimsBlogsWebServerFixture()
{ {
@ -86,7 +86,6 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
public void Dispose() public void Dispose()
{ {
if (_app is null) return;
_app.StopAsync().GetAwaiter().GetResult(); _app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().AsTask().GetAwaiter().GetResult(); _app.DisposeAsync().AsTask().GetAwaiter().GetResult();
@ -97,7 +96,6 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
} }
} }
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{ {
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)

View file

@ -5,7 +5,6 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
@ -72,6 +71,12 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
return post.Id; 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) private HttpClient NewClient(string subject)
{ {
var handler = new HttpClientHandler var handler = new HttpClientHandler
@ -95,13 +100,12 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice"); var postId = SeedPost("alice");
using var http = NewClient("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); 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); Assert.Equal(HttpStatusCode.OK, get.StatusCode);
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); 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()); Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
} }
@ -112,12 +116,11 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice"); var postId = SeedPost("alice");
using var http = NewClient("alice"); using var http = NewClient("alice");
await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken); await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true }, TestContext.Current.CancellationToken);
var put = await http.PutAsJsonAsync(_fixture.PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken); var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); 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)); using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean()); Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
} }
@ -127,7 +130,7 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{ {
ResetDatabase(); ResetDatabase();
using var http = NewClient("alice"); 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); Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
} }
@ -138,7 +141,7 @@ public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
var postId = SeedPost("alice"); var postId = SeedPost("alice");
using var http = NewClient("bob"); 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() // 401 Challenge (the controller returns Challenge()
// for AuthorizationFailureException). The exact code // for AuthorizationFailureException). The exact code
// is framework-dependent; what matters is "not 204". // is framework-dependent; what matters is "not 204".

View file

@ -9,7 +9,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -32,4 +32,7 @@
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -1,6 +1,6 @@
namespace Yavsc.Blogs; namespace Yavsc.Blogs;
public static class BlogConstants public static class Constants
{ {
public const string AdminRole = "Admin"; public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator"; public const string ModeratorRole = "Moderator";

View file

@ -9,7 +9,7 @@ namespace Yavsc.Blogs.Controllers
{ {
[Authorize("BlogScope")] [Authorize("BlogScope")]
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix + "/" + BlogSpotPath)] [Route(APIPrefix + "/blog")]
public class BlogApiController : Controller public class BlogApiController : Controller
{ {
private readonly BlogSpotService blogSpotService; private readonly BlogSpotService blogSpotService;
@ -19,14 +19,14 @@ namespace Yavsc.Blogs.Controllers
this.blogSpotService = blogSpotService; this.blogSpotService = blogSpotService;
} }
// GET: api/v1/blogspot // GET: api/BlogApi
[HttpGet] [HttpGet]
public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25) public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25)
{ {
return await blogSpotService.Index(User, null, start, take); return await blogSpotService.Index(User, null, start, take);
} }
// GET: api/v1/blogspot/5 // GET: api/BlogApi/5
[HttpGet("{id}", Name = "GetBlog")] [HttpGet("{id}", Name = "GetBlog")]
public async Task<IActionResult> GetBlog([FromRoute] long id) public async Task<IActionResult> GetBlog([FromRoute] long id)
{ {
@ -43,7 +43,7 @@ namespace Yavsc.Blogs.Controllers
return NotFound(); return NotFound();
} }
return Ok(blog.GetPayload()); return Ok(blog);
} }
catch (AuthorizationFailureException) catch (AuthorizationFailureException)
{ {
@ -51,7 +51,7 @@ namespace Yavsc.Blogs.Controllers
} }
} }
// PUT: api/v1/blogspot/5 // PUT: api/BlogApi/5
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
{ {
@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent); return new StatusCodeResult(StatusCodes.Status204NoContent);
} }
// POST: api/v1/blogspot // POST: api/v1/blog
[HttpPost] [HttpPost]
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{ {
@ -116,8 +116,7 @@ namespace Yavsc.Blogs.Controllers
: (IFormFileCollection)new FormFileCollection(); : (IFormFileCollection)new FormFileCollection();
var uid = User.GetUserId(); var uid = User.GetUserId();
var post = blogSpotService.Create(uid, blog, files); var post = blogSpotService.Create(uid, blog, files);
return CreatedAtRoute("GetBlog", new { id = post.Id }, return CreatedAtRoute("GetBlog", new { id = post.Id }, post);
post.GetPayload());
} }
// DELETE: api/BlogApi/5 // DELETE: api/BlogApi/5
@ -136,7 +135,7 @@ namespace Yavsc.Blogs.Controllers
} }
await blogSpotService.Delete(User, id); await blogSpotService.Delete(User, id);
return Ok(blog.GetPayload()); return Ok(blog);
} }
/// <summary> /// <summary>

View file

@ -6,7 +6,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix + "/" + BlogTagPath )] [Route(APIPrefix + "/blogtags")]
public class BlogTagsApiController : Controller public class BlogTagsApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -8,7 +8,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix +"/" + CirclePath)] [Route(APIPrefix +"/circle")]
public class CircleApiController : Controller public class CircleApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -11,7 +11,7 @@ namespace Yavsc.Blogs.Controllers
{ {
[Authorize] [Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix + "/" + CommentsPath)] [Route(APIPrefix + "/blogcomments")]
public class CommentsApiController : Controller public class CommentsApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -4,15 +4,18 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId> <UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId>
<RootNamespace>Yavsc.Blogs</RootNamespace> <RootNamespace>Yavsc.Blogs</RootNamespace>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" /> <ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -7,5 +7,7 @@
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,19 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using MailKit.Net.Smtp;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MimeKit;
using Yavsc.Interface; using Yavsc.Interface;
using Yavsc.Interfaces; using Yavsc.Interfaces;
using Yavsc.Models.Relationship;
using Yavsc.Org.Tests.Fakes; using Yavsc.Org.Tests.Fakes;
using Yavsc.Services;
using Yavsc.Settings;
using Yavsc.ViewModels.Account;
namespace Yavsc.Org.Tests namespace Yavsc.Org.Tests
{ {
@ -66,97 +55,5 @@ namespace Yavsc.Org.Tests
client.Calls.Select(c => c.Kind).ToArray()); client.Calls.Select(c => c.Kind).ToArray());
Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address); 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<ValidationResult>();
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<MailSender>
{
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<LocalizedString> GetAllStrings(bool includeParentCultures)
=> Enumerable.Empty<LocalizedString>();
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;
}
} }
} }

View file

@ -11,7 +11,7 @@
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath> <RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -86,4 +86,7 @@
</ItemGroup> </ItemGroup>
<Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)" DestinationFolder="$(OutDir)" /> <Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)" DestinationFolder="$(OutDir)" />
</Target> </Target>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -564,8 +564,6 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterModel model) public async Task<IActionResult> Register(RegisterModel model)
{ {
model.Email = model.Email?.Trim();
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
var user = new ApplicationUser { UserName = model.UserName, Email = model.Email }; var user = new ApplicationUser { UserName = model.UserName, Email = model.Email };

View file

@ -20,5 +20,6 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" /> <PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
<PackageVersion Include="YamlDotNet" Version="18.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions; using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
@ -98,7 +97,6 @@ public class OldBlogSpotService
throw new AuthorizationFailureException(auth); throw new AuthorizationFailureException(auth);
} }
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id); var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
ScrubAclForViewer(blog, user);
return new BlogPostEditViewModel(blog, pub); return new BlogPostEditViewModel(blog, pub);
} }
@ -120,7 +118,6 @@ public class OldBlogSpotService
{ {
throw new AuthorizationFailureException(auth); throw new AuthorizationFailureException(auth);
} }
ScrubAclForViewer(blog, user);
foreach (var c in blog.Comments) foreach (var c in blog.Comments)
{ {
c.Author = _context.Users.First(u => u.Id == c.AuthorId); c.Author = _context.Users.First(u => u.Id == c.AuthorId);
@ -192,14 +189,13 @@ public class OldBlogSpotService
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25) public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
{ {
string? viewerId = user.Identity?.IsAuthenticated == true ? user.GetUserId() : null;
IEnumerable<IBlogPost> posts; IEnumerable<IBlogPost> posts;
if (user.Identity.IsAuthenticated) if (user.Identity.IsAuthenticated)
{ {
string viewerIdNonNull = viewerId!; string viewerId = user.GetUserId();
long[] userCircles = await _context.Circle.Include(c => c.Members). 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(); .Select(c => c.Id).ToArrayAsync();
posts = _context.BlogSpot posts = _context.BlogSpot
@ -209,7 +205,7 @@ public class OldBlogSpotService
.Include(p => p.Comments) .Include(p => p.Comments)
.Where(p => p.ACL == null .Where(p => p.ACL == null
|| p.ACL.Count == 0 || p.ACL.Count == 0
|| (p.AuthorId == viewerIdNonNull) || (p.AuthorId == viewerId)
|| (userCircles != null && || (userCircles != null &&
p.ACL.Any(a => userCircles.Contains(a.CircleId))) p.ACL.Any(a => userCircles.Contains(a.CircleId)))
); );
@ -227,11 +223,7 @@ public class OldBlogSpotService
.Select(p => p.BlogPost).ToArray(); .Select(p => p.BlogPost).ToArray();
} }
var materialised = posts.ToList(); var data = posts.OrderByDescending(p => p.DateModified)
foreach (var post in materialised.OfType<Yavsc.Models.Blog.BlogPost>())
ScrubAclForViewer(post, user);
var data = materialised.OrderByDescending(p => p.DateModified)
.Skip(skip) .Skip(skip)
.Take(take); .Take(take);
return data; return data;
@ -254,11 +246,7 @@ public class OldBlogSpotService
{ {
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null; string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>(); if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
var posts = _context.UserPosts(posterId, readerId).ToList(); return _context.UserPosts(posterId, readerId);
var viewerId = string.Equals(readerId, posterId, StringComparison.Ordinal) ? readerId : null;
foreach (var post in posts)
ScrubAclForViewer(post, viewerId);
return posts;
} }
public object? GetTitle(string title) public object? GetTitle(string title)
@ -278,39 +266,4 @@ public class OldBlogSpotService
.SingleOrDefaultAsync(x => x.Id == value); .SingleOrDefaultAsync(x => x.Id == value);
} }
private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user)
{
if (!IsOwner(post, user))
post.ACL = new List<CircleAuthorizationToBlogPost>();
}
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<CircleAuthorizationToBlogPost>();
}
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;
}
} }

View file

@ -70,7 +70,7 @@
<div class="actiongroup"> <div class="actiongroup">
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded) @if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
{ {
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a> <a asp-action="Details" asp-route-id="@((IBlogPost)post).Id" class="btn btn-light">Details</a>
} }
else else
{ {

View file

@ -93,8 +93,8 @@ A operação é anulável até duas semanas após a sua programação.
<environment names="Lua,Development"> <environment names="Lua,Development">
<asciidoc>Este é o meu site perso, uma configuração de _Yavsc_ (outro negócio muito pequeno). <asciidoc>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) * [README](https://github.com/pazof/yavsc/blob/vnext/README.md)
* [licença: GNU GPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) * [licença: GNU GPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE)
Outras instalações: Outras instalações:
</asciidoc> </asciidoc>
@ -109,8 +109,8 @@ Outras instalações:
<asciidoc> <asciidoc>
Yet Another Very Small Company ... Yet Another Very Small Company ...
* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) * [README](https://github.com/pazof/yavsc/blob/vnext/README.md)
* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) * [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE)
</asciidoc> </asciidoc>
</environment> </environment>
@ -118,8 +118,8 @@ Outras instalações:
<environment names="YavscPre"> <environment names="YavscPre">
<asciidoc> <asciidoc>
## Yet Another Very Small Company : ## Yet Another Very Small Company :
* [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) * [README](https://github.com/pazof/yavsc/blob/vnext/README.md)
* [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) * [license: GNU FPL v3](https://github.com/pazof/yavsc/blob/vnext/LICENSE)
En production: En production:

View file

@ -9,7 +9,7 @@
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -51,4 +51,7 @@
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" /> <ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

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

View file

@ -66,9 +66,9 @@ namespace Yavsc.Models.Blog
return ACL?.Any(i => i.CircleId == circleId) ?? true; return ACL?.Any(i => i.CircleId == circleId) ?? true;
} }
public CircleAuthorization[] GetACL() public ICircleAuthorization[] GetACL()
{ {
return ACL?.ToArray() ?? Array.Empty<CircleAuthorization>(); return ACL?.ToArray() ?? Array.Empty<ICircleAuthorization>();
} }
public void Tag(Tag tag) public void Tag(Tag tag)
@ -85,7 +85,7 @@ namespace Yavsc.Models.Blog
public string[] GetTags() public string[] GetTags()
{ {
return Tags?.Select(t => t.Tag.Name).ToArray() ?? Array.Empty<string>(); return Tags.Select(t => t.Tag.Name).ToArray();
} }
[InverseProperty("Post")] [InverseProperty("Post")]
@ -106,7 +106,6 @@ namespace Yavsc.Models.Blog
[NotMapped] [NotMapped]
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
[JsonIgnore]
/// <summary> /// <summary>
/// Explicit interface implementation of /// Explicit interface implementation of
/// <see cref="IBlogPost.Author"/>. The underlying /// <see cref="IBlogPost.Author"/>. The underlying
@ -134,16 +133,5 @@ namespace Yavsc.Models.Blog
}; };
} }
} }
ICollection<CircleAuthorization> ICircleAuthorized.ACL
{
get
{
return ACL?.Select(a => new CircleAuthorization
{
CircleId = a.CircleId
}).ToList() ?? new List<CircleAuthorization>();
}
}
} }
} }

View file

@ -1,17 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
namespace Yavsc.Models.Blog namespace Yavsc.Models.Blog
{ {
public partial class BlogTag public partial class BlogTag
{ {
[JsonIgnore]
[ForeignKey("PostId")] [ForeignKey("PostId")]
public virtual BlogPost Post { get; set; } public virtual BlogPost Post { get; set; }
public long PostId { get; set; } public long PostId { get; set; }
[JsonIgnore]
[ForeignKey("TagId")] [ForeignKey("TagId")]
public virtual Tag Tag{ get; set; } public virtual Tag Tag{ get; set; }
public long TagId { get; set; } public long TagId { get; set; }

View file

@ -14,16 +14,14 @@ namespace Yavsc.Models.Blog
[YaStringLength(1024)] [YaStringLength(1024)]
public string Article { get; set; } public string Article { get; set; }
[JsonIgnore] [ForeignKeyAttribute(nameof(ReceiverId))][JsonIgnore]
[ForeignKeyAttribute(nameof(ReceiverId))]
public virtual BlogPost Post { get; set; } public virtual BlogPost Post { get; set; }
[Required] [Required]
public long ReceiverId { get; set; } public long ReceiverId { get; set; }
public bool Visible { get; set; } public bool Visible { get; set; }
[ForeignKeyAttribute("AuthorId")] [ForeignKeyAttribute("AuthorId")][JsonIgnore]
[JsonIgnore]
public virtual ApplicationUser Author { public virtual ApplicationUser Author {
get; set; get; set;
} }

View file

@ -1,16 +1,15 @@
using System.Diagnostics; using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Blogspot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions; using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.ViewModels.Auth; using Yavsc.ViewModels.Auth;
using Microsoft.AspNetCore.Http;
using Yavsc.Blogspot;
public class BlogSpotService public class BlogSpotService
{ {
@ -18,8 +17,7 @@ public class BlogSpotService
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly IFileSystemAuthManager fileSystemAuthManager; private readonly IFileSystemAuthManager fileSystemAuthManager;
public BlogSpotService( public BlogSpotService(ApplicationDbContext context,
ApplicationDbContext context,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
IFileSystemAuthManager fileSystemAuthManager) IFileSystemAuthManager fileSystemAuthManager)
{ {
@ -28,15 +26,15 @@ public class BlogSpotService
this.fileSystemAuthManager = fileSystemAuthManager; 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 // 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; post.AuthorId = userId;
_context.BlogSpot.Add(post); _context.BlogSpot.Add(post);
_context.SaveChanges(userId); _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) if (files != null && files.Count > 0)
{ {
var user = _context.Users.FirstOrDefault(u => u.Id == userId); var user = _context.Users.FirstOrDefault(u => u.Id == userId);
@ -44,19 +42,23 @@ public class BlogSpotService
{ {
try try
{ {
// Créer un répertoire pour les fichiers du blog
string blogFilesSubdir = $"blogs/{post.Id}"; string blogFilesSubdir = $"blogs/{post.Id}";
string destDir = Path.Combine( string destDir = Path.Combine(
AbstractFileSystemHelpers.UserFilesDirName, AbstractFileSystemHelpers.UserFilesDirName,
user.UserName, user.UserName,
blogFilesSubdir); blogFilesSubdir
);
var di = new DirectoryInfo(destDir); var di = new DirectoryInfo(destDir);
if (!di.Exists) di.Create(); if (!di.Exists) di.Create();
// Traiter chaque fichier
foreach (var formFile in files) foreach (var formFile in files)
{ {
var fileInfo = user.ReceiveUserFile(destDir, formFile); var fileInfo = user.ReceiveUserFile(destDir, formFile);
if (fileInfo != null && !fileInfo.QuotaOffense) if (fileInfo != null && !fileInfo.QuotaOffense)
{ {
// Créer une entrée UploadedFile si nécessaire
var uploadedFile = new UploadedFile var uploadedFile = new UploadedFile
{ {
Path = fileInfo.FileName, Path = fileInfo.FileName,
@ -66,6 +68,7 @@ public class BlogSpotService
_context.UploadedFiles.Add(uploadedFile); _context.UploadedFiles.Add(uploadedFile);
_context.SaveChanges(userId); _context.SaveChanges(userId);
// Lier le fichier au post
var attachment = new BlogAttachedFile var attachment = new BlogAttachedFile
{ {
PostId = post.Id, PostId = post.Id,
@ -78,56 +81,52 @@ public class BlogSpotService
} }
catch (Exception ex) 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; return post;
} }
public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId) public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
{ {
var blog = await _context.BlogSpot var blog = await _context.BlogSpot.Include(x => x.Author).Include(x => x.ACL).SingleAsync(m => m.Id == blogPostId);
.Include(x => x.Author)
.Include(x => x.ACL)
.SingleAsync(m => m.Id == blogPostId);
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
if (!auth.Succeeded) if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth); throw new AuthorizationFailureException(auth);
}
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id); var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
ScrubAclForViewer(blog, user);
return new BlogPostEditViewModel(blog, pub); return new BlogPostEditViewModel(blog, pub);
} }
public async Task<BlogPost> Details(ClaimsPrincipal user, long blogPostId) public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
{ {
BlogPost blog = await _context.BlogSpot Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
.Include(p => p.Author) .Include(p => p.Author)
.Include(p => p.Tags) .Include(p => p.Tags)
.Include(p => p.Comments) .Include(p => p.Comments)
.Include(p => p.ACL) .Include(p => p.ACL)
.SingleAsync(m => m.Id == blogPostId); .SingleAsync(m => m.Id == blogPostId);
if (blog == null) if (blog == null)
{
return 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 blog.IsPublished = await _context.blogSpotPublications
.AnyAsync(pub => pub.BlogpostId == blogPostId); .AnyAsync(pub => pub.BlogpostId == blogPostId);
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission()); var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
if (!auth.Succeeded) if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth); throw new AuthorizationFailureException(auth);
}
ScrubAclForViewer(blog, user);
foreach (var c in blog.Comments) foreach (var c in blog.Comments)
{
c.Author = _context.Users.First(u => u.Id == c.AuthorId); c.Author = _context.Users.First(u => u.Id == c.AuthorId);
}
return blog; return blog;
} }
@ -135,45 +134,54 @@ public class BlogSpotService
{ {
var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id); var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id);
Debug.Assert(blog != null); Debug.Assert(blog != null);
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission()); var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
if (!auth.Succeeded) if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth); throw new AuthorizationFailureException(auth);
}
blog.Article = blogEdit.Article; blog.Article = blogEdit.Article;
blog.Title = blogEdit.Title; blog.Title = blogEdit.Title;
blog.Photo = blogEdit.Photo; blog.Photo = blogEdit.Photo;
blog.ACL = blogEdit.ACL; blog.ACL = blogEdit.ACL;
// saves the change
_context.Update(blog); _context.Update(blog);
var publication = await _context.blogSpotPublications.SingleOrDefaultAsync
var publication = await _context.blogSpotPublications (p => p.BlogpostId == blogEdit.Id);
.SingleOrDefaultAsync(p => p.BlogpostId == blogEdit.Id);
if (publication != null) if (publication != null)
{ {
if (!blogEdit.Publish) if (!blogEdit.Publish)
{
_context.blogSpotPublications.Remove(publication); _context.blogSpotPublications.Remove(publication);
} }
else if (blogEdit.Publish)
{
_context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = blogEdit.Id });
} }
else
{
if (blogEdit.Publish)
{
_context.blogSpotPublications.Add(
new BlogSpotPublication
{
BlogpostId = blogEdit.Id
}
);
}
}
_context.SaveChanges(user.GetUserId()); _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 var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
.Include(b => b.ACL)
.SingleOrDefaultAsync(b => b.Id == blog.Id);
if (existing == null) if (existing == null)
{
throw new InvalidOperationException($"Blog post {blog.Id} not found."); throw new InvalidOperationException($"Blog post {blog.Id} not found.");
}
var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission()); var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission());
if (!auth.Succeeded) if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth); throw new AuthorizationFailureException(auth);
}
existing.Title = blog.Title; existing.Title = blog.Title;
existing.Article = blog.Article; existing.Article = blog.Article;
@ -191,10 +199,9 @@ public class BlogSpotService
if (user.Identity.IsAuthenticated) if (user.Identity.IsAuthenticated)
{ {
string viewerId = user.GetUserId(); string viewerId = user.GetUserId();
long[] userCircles = await _context.Circle.Include(c => c.Members) long[] userCircles = await _context.Circle.Include(c => c.Members).
.Where(c => c.Members.Any(m => m.MemberId == viewerId)) Where(c => c.Members.Any(m => m.MemberId == viewerId))
.Select(c => c.Id) .Select(c => c.Id).ToArrayAsync();
.ToArrayAsync();
posts = _context.BlogSpot posts = _context.BlogSpot
.Include(b => b.Author) .Include(b => b.Author)
@ -203,8 +210,10 @@ public class BlogSpotService
.Include(p => p.Comments) .Include(p => p.Comments)
.Where(p => p.ACL == null .Where(p => p.ACL == null
|| p.ACL.Count == 0 || p.ACL.Count == 0
|| p.AuthorId == viewerId || (p.AuthorId == viewerId)
|| (userCircles != null && p.ACL.Any(a => userCircles.Contains(a.CircleId)))); || (userCircles != null &&
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
);
} }
else else
{ {
@ -214,13 +223,20 @@ public class BlogSpotService
.Include(p => p.BlogPost.ACL) .Include(p => p.BlogPost.ACL)
.Include(p => p.BlogPost.Tags) .Include(p => p.BlogPost.Tags)
.Include(p => p.BlogPost.Comments) .Include(p => p.BlogPost.Comments)
.Where(p => p.BlogPost.ACL == null || p.BlogPost.ACL.Count == 0) .Where(p => p.BlogPost.ACL == null
.Select(p => p.BlogPost) || p.BlogPost.ACL.Count == 0)
.ToArray(); .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(); 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(); var postIds = materialised.Select(p => p.Id).ToList();
if (postIds.Count > 0) if (postIds.Count > 0)
{ {
@ -228,15 +244,11 @@ public class BlogSpotService
.Where(pub => postIds.Contains(pub.BlogpostId)) .Where(pub => postIds.Contains(pub.BlogpostId))
.Select(pub => pub.BlogpostId) .Select(pub => pub.BlogpostId)
.ToListAsync(); .ToListAsync();
var publishedSet = publishedIds.ToHashSet(); var publishedSet = publishedIds.ToHashSet();
foreach (var post in materialised.OfType<BlogPost>()) foreach (var post in materialised.OfType<Yavsc.Models.Blog.BlogPost>())
post.IsPublished = publishedSet.Contains(post.Id); post.IsPublished = publishedSet.Contains(post.Id);
} }
foreach (var post in materialised.OfType<BlogPost>())
ScrubAclForViewer(post, user);
return materialised return materialised
.OrderByDescending(p => p.DateModified) .OrderByDescending(p => p.DateModified)
.Skip(skip) .Skip(skip)
@ -245,38 +257,34 @@ public class BlogSpotService
public async Task Delete(ClaimsPrincipal user, long id) 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.BlogSpot.Remove(blog);
_context.SaveChanges(user.GetUserId()); _context.SaveChanges(user.GetUserId());
} }
public async Task<IEnumerable<BlogPost>> UserPosts(string posterName, string? readerId, int pageLen = 10, int pageNum = 0) public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
string posterName,
string? readerId,
int pageLen = 10,
int pageNum = 0)
{ {
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id; string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
if (posterId == null) return Array.Empty<BlogPost>(); if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
return _context.UserPosts(posterId, readerId);
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<CircleAuthorizationToBlogPost>();
}
return posts;
} }
public object? GetTitle(string title) public object? GetTitle(string title)
{ {
return _context.BlogSpot return _context.BlogSpot.Include(
.Include(b => b.Author) b => b.Author
.Where(x => x.Title == title) ).Where(x => x.Title == title).OrderByDescending(
.OrderByDescending(x => x.DateCreated) x => x.DateCreated
.ToList(); ).ToList();
} }
public async Task<BlogPost?> GetBlogPostAsync(long value) public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
{ {
return await _context.BlogSpot return await _context.BlogSpot
.Include(b => b.Author) .Include(b => b.Author)
@ -284,6 +292,26 @@ public class BlogSpotService
.SingleOrDefaultAsync(x => x.Id == value); .SingleOrDefaultAsync(x => x.Id == value);
} }
/// <summary>
/// Toggle a post's publication state. <paramref name="publish"/>
/// true adds a row to <c>blogSpotPublications</c> (the post
/// becomes visible to anonymous callers via
/// <see cref="PermissionHandler.IsPublic"/>); false removes
/// the row if present.
///
/// <para>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.</para>
///
/// <para>This is the same toggle the
/// <see cref="BlogPostEditViewModel"/>-flavoured
/// <see cref="Modify(ClaimsPrincipal, BlogPostEditViewModel)"/>
/// 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.</para>
/// </summary>
public async Task<bool> SetPublishAsync(ClaimsPrincipal user, long postId, bool publish) public async Task<bool> SetPublishAsync(ClaimsPrincipal user, long postId, bool publish)
{ {
var blog = await _context.BlogSpot.SingleOrDefaultAsync(b => b.Id == postId); 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()); var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
if (!auth.Succeeded) if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth); 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 (publish)
{ {
if (existing == null) if (existing == null)
{
_context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId }); _context.blogSpotPublications.Add(new BlogSpotPublication { BlogpostId = postId });
} }
}
else else
{ {
if (existing != null) if (existing != null)
{
_context.blogSpotPublications.Remove(existing); _context.blogSpotPublications.Remove(existing);
} }
}
await _context.SaveChangesAsync(user.GetUserId()); await _context.SaveChangesAsync(user.GetUserId());
return true; return true;
} }
private static void ScrubAclForViewer(BlogPost post, ClaimsPrincipal? user)
{
if (!IsOwner(post, user))
post.ACL = new List<CircleAuthorizationToBlogPost>();
}
private static bool IsOwner(BlogPost post, ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true) return false;
return string.Equals(user.GetUserId(), post.AuthorId, StringComparison.Ordinal);
}
} }

View file

@ -1,4 +1,3 @@
using MailKit.Net.Smtp;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -10,7 +9,6 @@ using Yavsc.Settings;
using Yavsc.Models; using Yavsc.Models;
using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using System.Text.RegularExpressions;
using System.Web; using System.Web;
namespace Yavsc.Services namespace Yavsc.Services
@ -55,56 +53,16 @@ namespace Yavsc.Services
/// </returns> /// </returns>
public Task SendEmailAsync(string email, string subject, string htmlMessage) public Task SendEmailAsync(string email, string subject, string htmlMessage)
{ {
return SendEmailAsync(null, email, subject, htmlMessage); return SendEmailAsync("", 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);
} }
public async Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage) public async Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
{
try
{ {
logger.LogInformation($"SendEmail for {email} : {subject}"); logger.LogInformation($"SendEmail for {email} : {subject}");
MimeMessage msg = new(); MimeMessage msg = new();
msg.From.Add(BuildMailboxAddress(siteSettings.Owner.Name, siteSettings.Owner.EMail)); msg.From.Add(new MailboxAddress(siteSettings.Owner.Name,
msg.To.Add(BuildMailboxAddress(name, email)); siteSettings.Owner.EMail));
msg.To.Add(new MailboxAddress(name, email));
TextPart text; TextPart text;
msg.Body = text = new TextPart("html") msg.Body = text = new TextPart("html")
{ {
@ -115,7 +73,7 @@ namespace Yavsc.Services
msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId( msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(
siteSettings.Authority siteSettings.Authority
); );
using Yavsc.Interfaces.ISmtpClient sc = _smtpClientFactory.CreateClient(); using ISmtpClient sc = _smtpClientFactory.CreateClient();
{ {
sc.Timeout = 30000; sc.Timeout = 30000;
sc.Connect( sc.Connect(
@ -135,22 +93,6 @@ namespace Yavsc.Services
} }
return msg.MessageId; 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;
}
}
public void SendEmailFromCriteria(string Criteria) public void SendEmailFromCriteria(string Criteria)
{ {

View file

@ -5,11 +5,11 @@
<UserSecretsId>53bd70e8-ff81-497a-847f-a15fd8ea7a09</UserSecretsId> <UserSecretsId>53bd70e8-ff81-497a-847f-a15fd8ea7a09</UserSecretsId>
<RootNamespace>Yavsc.Server</RootNamespace> <RootNamespace>Yavsc.Server</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<RepositoryUrl>https://forgejo.pschneider.fr/notazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -40,4 +40,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

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

Some files were not shown because too many files have changed in this diff Show more