From 857b752ec7412388ca7a7be8b28d9c574db0fa6e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 15 Aug 2026 15:51:42 +0100 Subject: [PATCH 001/294] postit: validate CHANGELOG section on tag, classify stable/preview/unstable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rend le job publish-release dépendant d'un nouveau job validate-release qui : - parse le tag (format MAJOR.MINOR.PATCH[-SUFFIX]) - classifie le canal : pair=stable, impair=preview, suffixe=instable - fail-fast sur instable sauf opt-in explicite via workflow_dispatch - vérifie que CHANGELOG.md contient une section ## [] - - expose le body de la section via $GITHUB_ENV pour le job de publication Le tag trigger passe de 'v*' à '*' (pas de préfixe sur les tags), et le corps de release GitHub est désormais curé via CHANGELOG.md plutôt que généré automatiquement. Cette convention de parité est partagée avec le dépôt postit-debian pour la production des paquets .deb (alignement à traiter dans une PR séparée). --- .github/workflows/docker-publish-android.yml | 128 +++++++++++++++++-- 1 file changed, 119 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml index 317cda809..ebf52a6d2 100644 --- a/.github/workflows/docker-publish-android.yml +++ b/.github/workflows/docker-publish-android.yml @@ -5,8 +5,14 @@ on: branches: - main tags: - - 'v*' + - '*' 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. @@ -41,11 +47,113 @@ jobs: path: ./PostIt.Android.apk retention-days: 7 - publish-release: - # Uniquement déclenché par un tag v*. Le job apk-deploy tourne en - # parallèle, on partage l'artefact entre jobs. + # 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/') - needs: apk-deploy + runs-on: ubuntu-latest + steps: + - name: Checkout du code + uses: actions/checkout@v7 + + - 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 suffixe. + # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". + if [[ "$BODY" != *" - $CHANNEL"* ]]; then + echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." + echo "Current section body (first 5 lines):" + echo "$BODY" | head -5 + exit 1 + fi + + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" + + # Exposition aux étapes suivantes via $GITHUB_ENV. + # heredoc <> "$GITHUB_ENV" + + publish-release: + # Déclenché uniquement par un push de tag. Le job apk-deploy produit + # l'artefact ; validate-release garantit la cohérence du tag et du + # changelog avant publication. + if: startsWith(github.ref, 'refs/tags/') + needs: [apk-deploy, validate-release] runs-on: ubuntu-latest steps: - name: Récupérer l'APK depuis l'artefact @@ -61,7 +169,9 @@ jobs: # apparaîtra dans l'asset et donc dans le permalink : # https://github.com///releases/latest/download/PostIt.Android.apk files: ./PostIt.Android.apk - # generate_release_notes: true -> évite d'avoir à maintenir - # le corps de release à la main. Décommente si tu veux. - # generate_release_notes: true - + # 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 }} From bdaeecc7b88e53063d20a34dbf66f9fc638ba742 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 15 Aug 2026 21:56:38 +0100 Subject: [PATCH 002/294] forgejo/ci: pin dotnet sdk 10.0.x for buildAndTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le repo cible net10.0 partout (csproj, TFM), mais le workflow CI Forgejo buildAndTest installait une SDK 9.0.x. Aligne sur 10.0.x pour que la CI build avec une SDK qui connaît le TFM net10.0. Pas de global.json ajouté : la SDK est résolue à l'installation de l'image runner, le repo reste agnostique de la version exacte. --- .forgejo/workflows/buildAndTest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index cda246ccd..c10b44395 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -44,7 +44,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build From 00c463a48e3d56ce65db62ecdb85cf8bea4c3f41 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 15 Aug 2026 22:18:03 +0100 Subject: [PATCH 003/294] forgejo/ci: run build in pazof/yavsc-build-env container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le workflow buildAndTest tournait sur un runner nu debian-latest avec setup-dotnet@v5 pour la SDK 10.0.x. Restore échouait car cet environnement n'a ni la source NuGet interne (isn.pschneider.fr) ni les workloads Android configurés, contrairement à l'image pazof/yavsc-build-env utilisée par le Dockerfile. Bascule le job sur un runner labelisé docker avec l'image debian12-dotnet10-android36-v1 directement. Le step setup-dotnet devient inutile (l'image a déjà la SDK 10.0), le restore partage la même config que le Dockerfile. Refs l'image cible par ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1. --- .forgejo/workflows/buildAndTest.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index c10b44395..12c47ac23 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -37,14 +37,10 @@ jobs: build: - runs-on: debian-latest + runs-on: docker://pazof/yavsc-build-env:debian12-dotnet10-android36-v1 steps: - uses: actions/checkout@v6 - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build From befc08dcb8f2c25696b4129b94f425c454356dd1 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 12:49:50 +0100 Subject: [PATCH 004/294] dotnet-android-build-image: pin to --- .gitmodules | 3 +++ external/dotnet-android-build-image | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 external/dotnet-android-build-image diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..6595fa7d6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/dotnet-android-build-image"] + path = external/dotnet-android-build-image + url = git@forgejo.pschneider.fr:notazof/dotnet-android-build-image.git diff --git a/external/dotnet-android-build-image b/external/dotnet-android-build-image new file mode 160000 index 000000000..0695a6c1f --- /dev/null +++ b/external/dotnet-android-build-image @@ -0,0 +1 @@ +Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d From f0f921b9de6e1088bd5134a8053f8295f9f7e37d Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:03:17 +0100 Subject: [PATCH 005/294] run on docker --- .forgejo/workflows/buildAndTest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 12c47ac23..28fee8379 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -37,7 +37,7 @@ jobs: build: - runs-on: docker://pazof/yavsc-build-env:debian12-dotnet10-android36-v1 + runs-on: docker steps: - uses: actions/checkout@v6 From 00ece4f21f266e38459e03d0f2aba04e64862eae Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:15:10 +0100 Subject: [PATCH 006/294] ci: replace actions/checkout with manual git clone (image has no node) The pazof/yavsc-build-env:debian12-dotnet10-android36-v1 image only ships .NET 10 SDK + Android SDK + JDK 17, no Node. actions/checkout@v6 requires Node, so the job failed with 'exec: node not found'. Replace actions/checkout with a direct git clone over HTTPS (Forgejo anonymous is enabled), and init submodules recursively. Also drop the bogus docker://image:tag runs-on: matcher, use just 'docker' to match the runner's declared label name. --- .forgejo/workflows/buildAndTest.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 28fee8379..e0b63433b 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -40,10 +40,19 @@ jobs: runs-on: docker steps: - - uses: actions/checkout@v6 + - name: Clone yavsc + run: | + cd "$RUNNER_WORKSPACE" + git clone --depth 1 --branch "${GITHUB_REF_NAME:-main}" \ + https://forgejo.pschneider.fr/notazof/yavsc.git _src + cd _src + git submodule update --init --recursive --depth 1 - name: Restore dependencies + working-directory: ${{ runner.workspace }}/_src run: dotnet restore - name: Build + working-directory: ${{ runner.workspace }}/_src run: dotnet build --no-restore - name: Test + working-directory: ${{ runner.workspace }}/_src run: dotnet test --no-build --verbosity normal From b168232c5b7610e99ba5ecd0bacd5a08cb8ebdce Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:22:56 +0100 Subject: [PATCH 007/294] ci: clone via GITHUB_REF instead of GITHUB_REF_NAME In pull_request context, GITHUB_REF_NAME is the PR number ('17'), not the source branch. Cloning --branch 17 fails with 'Could not find remote branch 17 to clone'. Use GITHUB_REF (refs/pull/N/head in PR context, refs/heads/ in push context) and fetch + checkout FETCH_HEAD. workflow_dispatch falls back to the default branch. --- .forgejo/workflows/buildAndTest.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index e0b63433b..7a19e8039 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -43,9 +43,12 @@ jobs: - name: Clone yavsc run: | cd "$RUNNER_WORKSPACE" - git clone --depth 1 --branch "${GITHUB_REF_NAME:-main}" \ - https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src cd _src + if [ -n "${GITHUB_REF:-}" ]; then + git fetch --depth 1 origin "$GITHUB_REF" + git checkout FETCH_HEAD + fi git submodule update --init --recursive --depth 1 - name: Restore dependencies working-directory: ${{ runner.workspace }}/_src From 4717cfb76e28b5ee1bd0d5f7d3f1c349054b1156 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:26:12 +0100 Subject: [PATCH 008/294] submodule: switch URL from SSH to HTTPS for forgejo anonymous access The runner container does not have an SSH client, and even if it did, no key is configured for it. Forgejo Actions must reach the submodule over HTTPS with anonymous read access (which is now enabled on the Forgejo instance). Use 'git submodule sync --recursive' on the developer side after checkout to propagate the URL change to .git/modules/. --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 6595fa7d6..eadf3c7fc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "external/dotnet-android-build-image"] path = external/dotnet-android-build-image - url = git@forgejo.pschneider.fr:notazof/dotnet-android-build-image.git + url = https://forgejo.pschneider.fr/notazof/dotnet-android-build-image.git From 8bfa9471480ef6cda98016de48166e374e375d17 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:27:36 +0100 Subject: [PATCH 009/294] ci: drop working-directory, cd into /src/_src explicitly forgejo-runner v13 does not interpolate ${{ runner.workspace }} in working-directory: (or ignores the field entirely for docker containers), so the container tried to chdir to '/_src' (literally) which does not exist. The image WORKDIR is /src, so clone directly into /src/_src and cd into it at the start of each step. Adds an echo of the checkout SHA + branch state for visibility in the log. --- .forgejo/workflows/buildAndTest.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 7a19e8039..2c0137bd5 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Clone yavsc run: | - cd "$RUNNER_WORKSPACE" + cd /src git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src cd _src if [ -n "${GITHUB_REF:-}" ]; then @@ -50,12 +50,10 @@ jobs: git checkout FETCH_HEAD fi git submodule update --init --recursive --depth 1 + echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" - name: Restore dependencies - working-directory: ${{ runner.workspace }}/_src - run: dotnet restore + run: cd /src/_src && dotnet restore - name: Build - working-directory: ${{ runner.workspace }}/_src - run: dotnet build --no-restore + run: cd /src/_src && dotnet build --no-restore - name: Test - working-directory: ${{ runner.workspace }}/_src - run: dotnet test --no-build --verbosity normal + run: cd /src/_src && dotnet test --no-build --verbosity normal From 7a9b831c1a72ad01555e09a329e98b0b2aee1648 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:35:44 +0100 Subject: [PATCH 010/294] nuget: add NuGet.config pointing at isn.pschneider.fr The yavsc solution depends on HigginsSoft.IdentityServer8.* 8.1.0-alpha.*, which is only published on the internal feed https://isn.pschneider.fr. Public nuget.org has 8.0.4 as the nearest version, so every project that uses IdentityServer8 (Yavsc.Org, Yavsc.Api, Yavsc.Blogs, Yavsc.Server, cli, Yavsc.Org.Tests, Yavsc.Blogs.Tests) fails with NU1102 on restore. Both feeds are reachable anonymously, so listing isn first and nuget.org second in a project-level config restores everything without credentials. The CI runner on forgejo now sees the same sources as a local clone. --- NuGet.config | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 NuGet.config diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 000000000..c601d09b5 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,23 @@ + + + + + + + + + From 19d5ff2ecb9c152c03d15b3ec6d30c127683ca84 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 13:39:53 +0100 Subject: [PATCH 011/294] ci: unshallow clone for GitVersion GitVersion.MsBuild fails on shallow clones ('Repository is a shallow clone. Git repositories must contain the full history.') because it walks the git log to compute the SemVer version. Drop --depth 1 from both the PR ref fetch and the submodule update so the runner's working tree has full history. The repo is small enough that the cost is negligible. --- .forgejo/workflows/buildAndTest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 2c0137bd5..9b3403dba 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -46,10 +46,10 @@ jobs: git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src cd _src if [ -n "${GITHUB_REF:-}" ]; then - git fetch --depth 1 origin "$GITHUB_REF" + git fetch origin "$GITHUB_REF" git checkout FETCH_HEAD fi - git submodule update --init --recursive --depth 1 + git submodule update --init --recursive 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 From cc942b33b74fd2965561c90eec24ab1d5f45579d Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 14:09:11 +0100 Subject: [PATCH 012/294] dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr' The project-level NuGet.config (added in 7a9b831c) lists the isn feed so 'dotnet restore' picks it up without an inline 'dotnet nuget add source' step. The inline add source was duplicating NuGet.config and causing build failures in GitHub Actions: - The --allow-insecure-connections flag did not match the actual HTTPS deployment of isn.pschneider.fr (Letsencrypt-issued cert, not self-signed), making the step fail with 'exit code 1'. - docker build --target build-env (used by .github/workflows/docker-publish-android.yml) hit this on every run. Both Dockerfile and Dockerfile.backend had the same redundant step; both removed. 'dotnet restore' still finds the feed via NuGet.config at /src/NuGet.config (copied in by 'COPY . .'). --- Dockerfile | 4 ---- Dockerfile.backend | 3 --- 2 files changed, 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 88b116830..795b70ab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,10 +46,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ # (2) Tout le code source COPY . . -# (3) Source NuGet interne (Letsencrypt, certificat auto-signé côté -# serveur, justifié par build privé). -RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json --allow-insecure-connections - # (4) Restore RUN dotnet restore diff --git a/Dockerfile.backend b/Dockerfile.backend index 76ad9ea08..a4e9a54a9 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -25,9 +25,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ # 4. Copie de l'intégralité du code source COPY . . -# 3. Restauration des dépendances avec vos workloads actifs -RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json - # 4. Restauration des dépendances pour tous les projets RUN dotnet restore From 3b4811fb21dc3c2ee8e5b6b720c9fac2fda1ccc6 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 14:09:11 +0100 Subject: [PATCH 013/294] dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr' The project-level NuGet.config (added in 7a9b831c) lists the isn feed so 'dotnet restore' picks it up without an inline 'dotnet nuget add source' step. The inline add source was duplicating NuGet.config and causing build failures in GitHub Actions: - The --allow-insecure-connections flag did not match the actual HTTPS deployment of isn.pschneider.fr (Letsencrypt-issued cert, not self-signed), making the step fail with 'exit code 1'. - docker build --target build-env (used by .github/workflows/docker-publish-android.yml) hit this on every run. Both Dockerfile and Dockerfile.backend had the same redundant step; both removed. 'dotnet restore' still finds the feed via NuGet.config at /src/NuGet.config (copied in by 'COPY . .'). --- Dockerfile | 4 ---- Dockerfile.backend | 3 --- 2 files changed, 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 88b116830..795b70ab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,10 +46,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ # (2) Tout le code source COPY . . -# (3) Source NuGet interne (Letsencrypt, certificat auto-signé côté -# serveur, justifié par build privé). -RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json --allow-insecure-connections - # (4) Restore RUN dotnet restore diff --git a/Dockerfile.backend b/Dockerfile.backend index 76ad9ea08..a4e9a54a9 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -25,9 +25,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ # 4. Copie de l'intégralité du code source COPY . . -# 3. Restauration des dépendances avec vos workloads actifs -RUN dotnet nuget add source https://isn.pschneider.fr/api/v3/index.json - # 4. Restauration des dépendances pour tous les projets RUN dotnet restore From 78b7dec6a157e3c5aa55bca94cc80d3d80b5ff42 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 16:18:37 +0100 Subject: [PATCH 014/294] release: 1.0.6 Patch is even (6) and bare, so this is classified as 'stable' by the validate-release job in .github/workflows/docker-publish-android.yml. Move the Unreleased section up by inserting [1.0.6] below it, with a list of changes that landed on this release: - Self-hosted Forgejo Actions runner now drives CI on yavsc, using pazof/yavsc-build-env:debian12-dotnet10-android36-v1 pulled from Docker Hub. - .forgejo/workflows/buildAndTest.yml builds without actions/checkout (image has no Node) and uses NuGet.config for the isn.pschneider.fr feed. - Dockerfile / Dockerfile.backend drop the redundant 'dotnet nuget add source' step that broke the APK build on GitHub Actions. --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5960706..44ec2b56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,4 +26,27 @@ pour la production des paquets `.deb`. ### Removed +## [1.0.6] - stable + +### Added +- Self-hosted Forgejo Actions runner now drives the CI build for the + yavsc repository, using the + `pazof/yavsc-build-env:debian12-dotnet10-android36-v1` image pulled + from Docker Hub. Workflow runs end-to-end: clone, restore, build, + test, with NuGet.config picking up the `isn.pschneider.fr` feed. + +### Changed +- CI workflow `.forgejo/workflows/buildAndTest.yml` no longer relies on + `actions/checkout` (the runner image has no Node); clones yavsc via + `git`, fetches the ref under test, and initializes submodules over + HTTPS. + +### Fixed +- `Dockerfile` and `Dockerfile.backend` no longer carry a redundant + `dotnet nuget add source` step that conflicted with the GitHub + Actions APK build (`--allow-insecure-connections` on an HTTPS + endpoint, exit 1). `NuGet.config` at the repo root supplies the + `isn.pschneider.fr` feed for every restore, including inside Docker. + [Unreleased]: https://github.com/pazof/yavsc/compare/HEAD +[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 From a7819bb88b6467eb5a9e09b04be715d20ac1db75 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 16:35:05 +0100 Subject: [PATCH 015/294] fix(billing): tolerate ReflectionTypeLoadException during init ConfigureBillingService() walks AppDomain.CurrentDomain.GetAssemblies() and calls Assembly.GetTypes() on each. If any of the loaded assemblies has a type that fails to resolve (a flaky dependency, an AddOn with a broken reference, a test dependency that's been rewritten after compile), GetTypes() throws ReflectionTypeLoadException (or, less commonly, FileNotFoundException / TypeLoadException for the assembly itself). In CI on the forgejo-runner (and especially in test discovery under xunit v3), one such assembly is loaded somewhere between test runs and silently throws. The exception is not handled, so: 1. Collections are Cleared at the top of ConfigureBillingService(). 2. The reflection loop throws before reaching the RegisterBilling calls. 3. BillingService.Billing ends up empty (Count = 0). 4. The second ConfigureBillingService() call sees the same assembly loaded (xunit v3 keeps the AppDomain warm for the whole suite), throws identically, and the test Yavsc.BillingServiceTests.ConfigureBillingService_CanBeCalledTwiceWithoutThrowing fails with 'Assert.Equal() Failure: Expected 3, Actual 0'. Fix: catch ReflectionTypeLoadException and use the partial .Types() list (the successfully-resolved subset), and use a broader catch (with continue) for any other assembly-level load failure. The lost user-settings types are not material; they are derived from ApplicationDbContext in a separate loop right after, and the RegisterBilling<>() calls that populate BillingService.Billing run last, after both reflective phases have completed best-effort. The test still passes locally because the local test environment loads a clean set of assemblies; only the CI runner (with its extra test-time tooling) hits this path. --- src/Yavsc.Server/Helpers/WorkflowHelpers.cs | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs index 8be68ea02..23adb981e 100644 --- a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs +++ b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs @@ -70,7 +70,27 @@ namespace Yavsc.Helpers foreach (var a in System.AppDomain.CurrentDomain.GetAssemblies()) { - foreach (var c in a.GetTypes()) + Type[] types; + try + { + types = a.GetTypes(); + } + catch (System.Reflection.ReflectionTypeLoadException rtle) + { + // Some referenced types failed to load; keep the + // ones that did and skip the rest so a flaky + // dependency in one assembly does not break + // billing initialization for every other assembly. + types = rtle.Types.Where(t => t != null).ToArray(); + } + catch + { + // Assembly itself cannot be loaded (FileNotFoundException + // on a referenced assembly, etc.). Skip it entirely. + continue; + } + + foreach (var c in types) { if (c.IsClass && !c.IsAbstract && c.GetInterface(nameof(IUserSettings)) != null) From ef821aa1342e3ece089d780ea974fd508d214428 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 16 Aug 2026 16:46:48 +0100 Subject: [PATCH 016/294] ci: re-check CI on release/1.0.6 with billing init fix From f1c80e51a47943ab8bc77caf8f7c6fe0dc6b08bc Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 00:06:32 +0100 Subject: [PATCH 017/294] Checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. complet de l’historique Git et des tags dans le job qui build l’APK via Docker: 2. with tags --- .github/workflows/docker-publish-android.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml index ebf52a6d2..94f190c25 100644 --- a/.github/workflows/docker-publish-android.yml +++ b/.github/workflows/docker-publish-android.yml @@ -25,6 +25,9 @@ jobs: 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 @@ -59,6 +62,9 @@ jobs: 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: From 6cce70b4d881572144cf2832641304d3d4fe982a Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 00:46:51 +0100 Subject: [PATCH 018/294] ci(forgejo): publish release with PostIt APK on tag push Adds .forgejo/workflows/release.yml: triggered by tag push or workflow_dispatch, it validates the tag/CHANGELOG parity (stable / preview / unstable), builds the PostIt Android APK via the existing Dockerfile (--target build-env), and publishes a Forgejo release with the APK as an asset via rasterstate/forgejo-release-action@v1. Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml so the two channels (Forgejo source-of-truth + GitHub mirror) stay consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo PAT scoped to write:repository configured in the repository's Actions secrets. --- .forgejo/workflows/release.yml | 227 +++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .forgejo/workflows/release.yml diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 000000000..008ee3f31 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,227 @@ +# Build and publish a release on the Forgejo source-of-truth instance +# with the PostIt Android APK as an attached asset. +# +# Triggered by a push of a git tag. Validates the tag/changelog pair, +# builds the APK using the existing Dockerfile (--target build-env), then +# publishes a Forgejo release via rasterstate/forgejo-release-action and +# uploads the APK as an asset. +# +# Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo PAT scoped +# to `write:repository` configured in the repository's Actions secrets. +# The runner-provided ${{ secrets.GITHUB_TOKEN }} would also work, but +# a dedicated PAT is preferred for least-privilege and revocability. +# +# This workflow complements .github/workflows/docker-publish-android.yml +# which targets the GitHub mirror; the validate-release logic mirrors +# the GitHub-side job so the two channels stay consistent. +name: Forgejo Release + +on: + push: + tags: + - '*' + workflow_dispatch: + inputs: + tag: + description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).' + required: true + type: string + 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 + +permissions: + contents: write + +jobs: + # Parse le tag, applique la règle de parité du patch + # (pair=stable / impair=preview / suffixe=unstable), fail-fast sur + # instable sauf opt-in, et vérifie que CHANGELOG.md contient une + # section `## [TAG] - ` cohérente. Le body est extrait + # dans un artifact consommé par le job release. + validate-release: + runs-on: docker + 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: + # En push tag : github.ref_name est le tag. + # En workflow_dispatch : on lit l'input 'tag' (obligatoire). + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + + # 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. + 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 suffixe. + # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". + if [[ "$BODY" != *" - $CHANNEL"* ]]; then + echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." + echo "Current section body (first 5 lines):" + echo "$BODY" | head -5 + exit 1 + fi + + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" + + # Écrit le body dans un fichier pour transmission via artifact. + # Le body est multi-ligne, donc artifact > heredoc $GITHUB_ENV. + mkdir -p release-body + printf '%s\n' "$BODY" > release-body/body.md + + - name: Uploader le body de la release comme artifact + uses: actions/upload-artifact@v7 + with: + name: release-body + path: release-body/body.md + retention-days: 1 + + # Construit l'APK via le Dockerfile (stage build-env), puis publie + # la release Forgejo avec le body validé et l'APK en asset. + release: + needs: validate-release + runs-on: docker + steps: + - name: Checkout du code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Checkout du tag (workflow_dispatch uniquement) + # En push tag, le runner checkout déjà au bon commit. + # En workflow_dispatch, on checkout explicitement le tag demandé + # pour que l'APK soit bien construit depuis ce commit. + if: github.event_name == 'workflow_dispatch' + env: + TAG: ${{ inputs.tag }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + git checkout "$TAG" + + - name: Build de l'image Docker (stage build-env uniquement) + run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . + + - name: Extraire l'APK signé du conteneur + run: | + docker create --name extractor postit-android + docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk ./PostIt.Android.apk + docker rm extractor + + - name: Récupérer le body validé + uses: actions/download-artifact@v7 + with: + name: release-body + path: release-body + + - name: Calculer le canal (stable / preview / unstable) depuis le tag + # On re-parse le tag ici plutôt que de transporter le channel + # via artifact. Le calcul est trivial (parité du patch + suffixe) + # et reste ainsi explicite. + id: set-channel + env: + # En push tag : github.ref_name est le tag. + # En workflow_dispatch : on lit l'input 'tag'. + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then + echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." + exit 1 + fi + PATCH="${BASH_REMATCH[3]}" + SUFFIX="${BASH_REMATCH[4]}" + if [[ -n "$SUFFIX" ]]; then + CHANNEL="unstable" + elif (( PATCH % 2 == 0 )); then + CHANNEL="stable" + else + CHANNEL="preview" + fi + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$([[ $CHANNEL != stable ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Publier la release Forgejo et uploader l'APK + uses: https://rasterhub.com/rasterstate/forgejo-release-action@v1 + with: + # tag_name defaults to the pushed tag (GITHUB_REF_NAME). + body_path: release-body/body.md + # Stable -> Latest (false). + # Preview et Unstable -> prerelease (true). + prerelease: ${{ steps.set-channel.outputs.is_prerelease }} + files: | + PostIt.Android.apk + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} \ No newline at end of file From 7de8e5a2600c22f77001ca12250108e8c8b357bf Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:23:44 +0100 Subject: [PATCH 019/294] ci(forgejo): use runner-provided GITHUB_TOKEN for release workflow Repo-level secrets creation is broken on this Forgejo instance (InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely a text-vs-bytea column type on the secret table). The fix is in upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto- provided by the runner, scoped to contents: write for the current repo) keeps the release workflow operational without any UI setup. When the instance is upgraded and the secret table is migrated, revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }} for least-privilege. --- .forgejo/workflows/release.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 008ee3f31..3dd00e7a8 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,10 +6,14 @@ # publishes a Forgejo release via rasterstate/forgejo-release-action and # uploads the APK as an asset. # -# Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo PAT scoped -# to `write:repository` configured in the repository's Actions secrets. -# The runner-provided ${{ secrets.GITHUB_TOKEN }} would also work, but -# a dedicated PAT is preferred for least-privilege and revocability. +# Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the +# Forgejo runner, scoped to contents: write for the current repo). A +# dedicated PAT (${{ secrets.RELEASE_TOKEN }}) was the preferred option +# for least-privilege, but creating repo-level secrets is currently +# broken on this Forgejo instance (InsertEncryptedSecret fails with a +# UTF-8 byte-sequence error, probably a text-vs-bytea column type on +# the secret table). Bumping to Forgejo v16 should fix it; until then, +# the runner-provided token keeps the workflow operational. # # This workflow complements .github/workflows/docker-publish-android.yml # which targets the GitHub mirror; the validate-release logic mirrors @@ -224,4 +228,4 @@ jobs: files: | PostIt.Android.apk env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From c934c7f43ca05a3a0285e59c068917d6dd06072a Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 00:06:32 +0100 Subject: [PATCH 020/294] Checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. complet de l’historique Git et des tags dans le job qui build l’APK via Docker: 2. with tags --- .github/workflows/docker-publish-android.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml index ebf52a6d2..94f190c25 100644 --- a/.github/workflows/docker-publish-android.yml +++ b/.github/workflows/docker-publish-android.yml @@ -25,6 +25,9 @@ jobs: 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 @@ -59,6 +62,9 @@ jobs: 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: From bb03a29b82d8598060ab4c9344c9e3d21e93b464 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:45:22 +0100 Subject: [PATCH 021/294] ci(forgejo): replace all Node-based actions with bash + curl The runner's docker label points at pazof/yavsc-build-env, a Debian image without Node.js. Any action like actions/checkout@v7, actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc. fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'. This workflow is rewritten in pure bash: - replace actions/checkout with explicit git clone + checkout (full history + tags so GitVersion.MsBuild is happy); - merge the two jobs into one (no inter-job artifacts needed since everything shares the runner's filesystem); - replace rasterstate/forgejo-release-action with direct calls to the Forgejo REST API (/api/v1/repos/.../releases, .../assets), with python3 used to build and parse JSON bodies (jq not guaranteed in the runner image). Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The rasterstate action or any other Node-based action can be reinstated later if the runner image is swapped for one with Node installed. --- .forgejo/workflows/release.yml | 229 +++++++++++++++++++-------------- 1 file changed, 132 insertions(+), 97 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3dd00e7a8..05def45dc 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -3,8 +3,8 @@ # # Triggered by a push of a git tag. Validates the tag/changelog pair, # builds the APK using the existing Dockerfile (--target build-env), then -# publishes a Forgejo release via rasterstate/forgejo-release-action and -# uploads the APK as an asset. +# publishes a Forgejo release via the Forgejo REST API and uploads the +# APK as an asset. # # Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the # Forgejo runner, scoped to contents: write for the current repo). A @@ -15,6 +15,12 @@ # the secret table). Bumping to Forgejo v16 should fix it; until then, # the runner-provided token keeps the workflow operational. # +# Why bash + curl, no third-party actions: the runner's docker label +# points at pazof/yavsc-build-env, a Debian image without Node.js. Any +# action like actions/checkout, rasterstate/forgejo-release-action, etc. +# fails with "executable file not found in $PATH". Same constraint as +# .forgejo/workflows/buildAndTest.yml. +# # This workflow complements .github/workflows/docker-publish-android.yml # which targets the GitHub mirror; the validate-release logic mirrors # the GitHub-side job so the two channels stay consistent. @@ -40,24 +46,15 @@ permissions: contents: write jobs: - # Parse le tag, applique la règle de parité du patch - # (pair=stable / impair=preview / suffixe=unstable), fail-fast sur - # instable sauf opt-in, et vérifie que CHANGELOG.md contient une - # section `## [TAG] - ` cohérente. Le body est extrait - # dans un artifact consommé par le job release. - validate-release: + # Job unique : validation tag/CHANGELOG + build APK + publication + # via l'API REST Forgejo (pas d'actions tierces Node). + release: runs-on: docker steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Valider le tag et la section CHANGELOG + - name: Clone du repo au tag demandé env: # En push tag : github.ref_name est le tag. - # En workflow_dispatch : on lit l'input 'tag' (obligatoire). + # En workflow_dispatch : on lit l'input 'tag'. TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }} run: | @@ -66,6 +63,27 @@ jobs: exit 1 fi + # WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile). + cd /src + + # Clone unshallow pour que GitVersion.MsBuild ait l'historique + # et les tags (sinon MSB3073 sur la cible Android cf. PR #21). + if [[ ! -d _src/.git ]]; then + git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src + fi + + cd _src + git fetch --tags --force --prune origin + git checkout "$TAG" + + echo "Checked out at $(git rev-parse HEAD) on $(git describe --tags --always 2>/dev/null || echo unknown)" + + - name: Valider le tag et la section CHANGELOG + run: | + cd /src/_src + TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || git rev-parse --short HEAD)" + echo "Validating tag $TAG" + # 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." @@ -92,7 +110,7 @@ jobs: echo "Tag $TAG classifié comme channel=$CHANNEL" # Fail-fast sur instable sauf opt-in explicite. - if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then + if [[ "$CHANNEL" == "unstable" && "${FORCE_UNSTABLE:-false}" != "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 @@ -134,98 +152,115 @@ jobs: echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - # Écrit le body dans un fichier pour transmission via artifact. - # Le body est multi-ligne, donc artifact > heredoc $GITHUB_ENV. - mkdir -p release-body - printf '%s\n' "$BODY" > release-body/body.md - - - name: Uploader le body de la release comme artifact - uses: actions/upload-artifact@v7 - with: - name: release-body - path: release-body/body.md - retention-days: 1 - - # Construit l'APK via le Dockerfile (stage build-env), puis publie - # la release Forgejo avec le body validé et l'APK en asset. - release: - needs: validate-release - runs-on: docker - steps: - - name: Checkout du code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Checkout du tag (workflow_dispatch uniquement) - # En push tag, le runner checkout déjà au bon commit. - # En workflow_dispatch, on checkout explicitement le tag demandé - # pour que l'APK soit bien construit depuis ce commit. - if: github.event_name == 'workflow_dispatch' - env: - TAG: ${{ inputs.tag }} - run: | - if [[ -z "$TAG" ]]; then - echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." - exit 1 - fi - git checkout "$TAG" + # Expose channel + body pour les étapes suivantes via $GITHUB_ENV. + echo "RELEASE_CHANNEL=$CHANNEL" >> "$GITHUB_ENV" + echo "RELEASE_BODY<> "$GITHUB_ENV" + echo "$BODY" >> "$GITHUB_ENV" + echo "EOF" >> "$GITHUB_ENV" + echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - name: Build de l'image Docker (stage build-env uniquement) - run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . + run: cd /src/_src && docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . - name: Extraire l'APK signé du conteneur run: | docker create --name extractor postit-android - docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk ./PostIt.Android.apk + docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk /src/_src/PostIt.Android.apk docker rm extractor - - name: Récupérer le body validé - uses: actions/download-artifact@v7 - with: - name: release-body - path: release-body - - - name: Calculer le canal (stable / preview / unstable) depuis le tag - # On re-parse le tag ici plutôt que de transporter le channel - # via artifact. Le calcul est trivial (parité du patch + suffixe) - # et reste ainsi explicite. - id: set-channel + - name: Publier la release Forgejo via l'API REST + # Pas d'action tierce (pas de Node dans l'image runner). + # On parle à l'API Forgejo directement via curl. + # Docs : https://forgejo.pschneider.fr/api/swagger#/repository/release env: - # En push tag : github.ref_name est le tag. - # En workflow_dispatch : on lit l'input 'tag'. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_REPOSITORY: ${{ github.repository }} TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + RELEASE_BODY: ${{ env.RELEASE_BODY }} + IS_PRERELEASE: ${{ env.IS_PRERELEASE }} run: | if [[ -z "$TAG" ]]; then - echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + echo "::error::No tag resolved for the API call." exit 1 fi - if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then - echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format." - exit 1 - fi - PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" - if [[ -n "$SUFFIX" ]]; then - CHANNEL="unstable" - elif (( PATCH % 2 == 0 )); then - CHANNEL="stable" - else - CHANNEL="preview" - fi - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" - echo "is_prerelease=$([[ $CHANNEL != stable ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - name: Publier la release Forgejo et uploader l'APK - uses: https://rasterhub.com/rasterstate/forgejo-release-action@v1 - with: - # tag_name defaults to the pushed tag (GITHUB_REF_NAME). - body_path: release-body/body.md - # Stable -> Latest (false). - # Preview et Unstable -> prerelease (true). - prerelease: ${{ steps.set-channel.outputs.is_prerelease }} - files: | - PostIt.Android.apk - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + # Le runner Forgejo expose l'API sur github.api_url (par + # défaut http://…/api/v1). On retire le suffixe /api/v1 s'il + # est présent pour dériver la base du serveur, puis on + # reconstruit l'URL de l'API proprement. + API_BASE="${GITHUB_API_URL%/}" + API_BASE="${API_BASE%/api/v1}" + + # 1. Vérifier si la release existe déjà pour ce tag. + echo "::group::Check existing release for tag $TAG" + HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/json" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG") + echo "GET releases/tags/$TAG -> HTTP $HTTP" + EXISTING_ID="" + if [[ "$HTTP" == "200" ]]; then + EXISTING_ID=$(python3 -c "import json,sys; print(json.load(open('/tmp/existing.json')).get('id',''))" 2>/dev/null || true) + echo "Existing release id: ${EXISTING_ID:-none}" + fi + echo "::endgroup::" + + # 2. Créer ou mettre à jour la release. + # On utilise python3 pour générer le body JSON proprement + # (jq n'est pas garanti dans l'image runner). + if [[ -n "$EXISTING_ID" ]]; then + echo "::group::Update release id=$EXISTING_ID" + BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" python3 -c 'import json,os; print(json.dumps({"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ + -X PATCH \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary "$BODY" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID") + echo "PATCH release -> HTTP $HTTP" + echo "::endgroup::" + else + echo "::group::Create release" + BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" TAG="$TAG" python3 -c 'import json,os; print(json.dumps({"tag_name":os.environ["TAG"],"name":os.environ["TAG"],"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary "$BODY" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases") + echo "POST release -> HTTP $HTTP" + echo "::endgroup::" + fi + + if [[ "$HTTP" != "200" && "$HTTP" != "201" ]]; then + echo "::error::Release creation/update failed (HTTP $HTTP):" + cat /tmp/release.json + exit 1 + fi + + RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])") + echo "Release id=$RELEASE_ID" + + # 3. Upload l'APK en asset. + echo "::group::Upload APK asset" + HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -H "Accept: application/json" \ + --data-binary "@/src/_src/PostIt.Android.apk" \ + "?name=PostIt.Android.apk" \ + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets") + echo "POST asset -> HTTP $HTTP" + echo "::endgroup::" + + if [[ "$HTTP" != "201" ]]; then + echo "::error::Asset upload failed (HTTP $HTTP):" + cat /tmp/asset.json + exit 1 + fi + + echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" \ No newline at end of file From cd236d91ec59e3f2cf90fb046c005374ccefdf18 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:49:45 +0100 Subject: [PATCH 022/294] ci(forgejo): check CHANGELOG channel suffix on the section title The previous awk extracted the section body but excluded the title line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never matched. Fix: include the title line in the extracted body, verify the channel suffix on the title, then strip the title before passing the body to the release API. --- .forgejo/workflows/release.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 05def45dc..59aafb611 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -125,12 +125,16 @@ jobs: # 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. + # paragraphe suffit et reste POSIX. On garde aussi le titre + # (ligne `## [TAG] - channel`) pour la vérification du canal. BODY=$(awk -v tag="[$TAG]" ' /^## \[/ { if (in_section) exit - if (index($0, tag) > 0) in_section=1 - next + if (index($0, tag) > 0) { + in_section=1 + print + next + } } in_section { print } ' CHANGELOG.md) @@ -143,13 +147,16 @@ jobs: # Vérification cohérence du canal déclaré dans le suffixe. # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - if [[ "$BODY" != *" - $CHANNEL"* ]]; then - echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section body (first 5 lines):" - echo "$BODY" | head -5 + # On lit la première ligne du body qui contient le titre. + TITLE=$(echo "$BODY" | head -1) + if [[ "$TITLE" != *" - $CHANNEL"* ]]; then + echo "::error::Section title '$TITLE' must declare suffix '- $CHANNEL' to match tag parity." exit 1 fi + # Body pour la release : retire la première ligne (titre). + BODY=$(echo "$BODY" | tail -n +2) + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" # Expose channel + body pour les étapes suivantes via $GITHUB_ENV. From 2a3ff9d69037ac00cb9ca13e743bd62c4313b1e1 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 01:56:01 +0100 Subject: [PATCH 023/294] ci(forgejo): build .NET projects directly, skip docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload Android, mais PAS le binaire 'docker' ni de daemon Docker. Le 'Build de l'image Docker' du workflow plantait avec 'docker: command not found'. Fix : on exécute directement les commandes dotnet du Dockerfile (restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r android-arm64), puis on copie l'APK depuis le chemin de sortie standard bin/Release/net10.0-android/android-arm64/. Note : le Dockerfile reste la voie canonique pour les builds en local et via GitHub Actions (qui a docker). Ce fix concerne uniquement le workflow Forgejo Actions où le runner n'a pas Docker. --- .forgejo/workflows/release.yml | 37 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 59aafb611..dc813fbe7 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -166,14 +166,37 @@ jobs: echo "EOF" >> "$GITHUB_ENV" echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV" - - name: Build de l'image Docker (stage build-env uniquement) - run: cd /src/_src && docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android . - - - name: Extraire l'APK signé du conteneur + - 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: | - docker create --name extractor postit-android - docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk /src/_src/PostIt.Android.apk - docker rm extractor + cd /src/_src + dotnet restore + dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly + dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \ + -c Release --no-restore -clp:ErrorsOnly -r android-arm64 + + - name: Copier l'APK signé vers un emplacement connu + # Le build Android avec -r android-arm64 produit l'APK dans + # bin/Release/net10.0-android/android-arm64/. On le copie à + # la racine du checkout pour que l'étape d'upload le trouve. + run: | + cd /src/_src + APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk + if [[ ! -f "$APK" ]]; then + echo "::error::APK not found at $APK" + ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true + exit 1 + fi + cp "$APK" /src/_src/PostIt.Android.apk + ls -la /src/_src/PostIt.Android.apk - name: Publier la release Forgejo via l'API REST # Pas d'action tierce (pas de Node dans l'image runner). From e6670a9b2dc279c804f4509534104cb769423679 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:00:12 +0000 Subject: [PATCH 024/294] Initial plan From 977f703d48c8014258341dae1069f8aecdfa9a84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:02:14 +0000 Subject: [PATCH 025/294] fix(ci): fix validate-release CHANGELOG channel check to inspect heading line Co-authored-by: pazof <3072814+pazof@users.noreply.github.com> --- .github/workflows/docker-publish-android.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish-android.yml b/.github/workflows/docker-publish-android.yml index 94f190c25..b9ee364cc 100644 --- a/.github/workflows/docker-publish-android.yml +++ b/.github/workflows/docker-publish-android.yml @@ -129,12 +129,12 @@ jobs: exit 1 fi - # Vérification cohérence du canal déclaré dans le suffixe. + # Vérification cohérence du canal déclaré dans le titre de section. # Format attendu : "## [TAG] - stable" / "- preview" / "- unstable". - if [[ "$BODY" != *" - $CHANNEL"* ]]; then + 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 body (first 5 lines):" - echo "$BODY" | head -5 + echo "Current section header: $HEADER" exit 1 fi From de0c671dff4589cbf935424acbab6216464182af Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 02:16:46 +0100 Subject: [PATCH 026/294] ci(forgejo): build JSON bodies in pure bash, no python3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni node). Le step de publication Forgejo utilisait python3 pour générer les bodies JSON (POST /releases, PATCH /releases/{id}) et pour extraire le 'id' de la réponse. Fix : deux fonctions bash : - json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t) - json_field : extraction d'un champ scalaire d'un fichier JSON via sed Suffisant pour les bodies qu'on envoie (tag_name, name, body, prerelease) et les champs qu'on lit (id). --- .forgejo/workflows/release.yml | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index dc813fbe7..9306d087c 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -222,6 +222,28 @@ jobs: API_BASE="${GITHUB_API_URL%/}" API_BASE="${API_BASE%/api/v1}" + # Pas de python3, pas de jq dans l'image runner. On génère + # le JSON à la main : escaping minimal des caractères + # spéciaux JSON dans les chaînes (\\, \", \n, \r, \t). + # Suffisant pour un CHANGELOG.md bien formé. + json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + printf '%s' "$s" + } + + # Extraction d'un champ JSON scalaire (string ou number) depuis + # un fichier. Utilise sed basique, suffisant pour les champs + # id / tag_name que l'API renvoie en clair. + json_field() { + local file="$1" field="$2" + sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([^\",}]*\)\"\?.*/\1/p" "$file" | head -1 + } + # 1. Vérifier si la release existe déjà pour ce tag. echo "::group::Check existing release for tag $TAG" HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \ @@ -231,17 +253,16 @@ jobs: echo "GET releases/tags/$TAG -> HTTP $HTTP" EXISTING_ID="" if [[ "$HTTP" == "200" ]]; then - EXISTING_ID=$(python3 -c "import json,sys; print(json.load(open('/tmp/existing.json')).get('id',''))" 2>/dev/null || true) + EXISTING_ID=$(json_field /tmp/existing.json id) echo "Existing release id: ${EXISTING_ID:-none}" fi echo "::endgroup::" # 2. Créer ou mettre à jour la release. - # On utilise python3 pour générer le body JSON proprement - # (jq n'est pas garanti dans l'image runner). if [[ -n "$EXISTING_ID" ]]; then echo "::group::Update release id=$EXISTING_ID" - BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" python3 -c 'import json,os; print(json.dumps({"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + BODY=$(printf '{"body":"%s","prerelease":%s}' \ + "$(json_escape "$RELEASE_BODY")" "$IS_PRERELEASE") HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X PATCH \ -H "Authorization: token $GITHUB_TOKEN" \ @@ -253,7 +274,11 @@ jobs: echo "::endgroup::" else echo "::group::Create release" - BODY=$(IS_PRERELEASE="$IS_PRERELEASE" RELEASE_BODY="$RELEASE_BODY" TAG="$TAG" python3 -c 'import json,os; print(json.dumps({"tag_name":os.environ["TAG"],"name":os.environ["TAG"],"body":os.environ["RELEASE_BODY"],"prerelease":os.environ["IS_PRERELEASE"].lower()=="true"}))') + BODY=$(printf '{"tag_name":"%s","name":"%s","body":"%s","prerelease":%s}' \ + "$(json_escape "$TAG")" \ + "$(json_escape "$TAG")" \ + "$(json_escape "$RELEASE_BODY")" \ + "$IS_PRERELEASE") HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X POST \ -H "Authorization: token $GITHUB_TOKEN" \ @@ -271,7 +296,7 @@ jobs: exit 1 fi - RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release.json'))['id'])") + RELEASE_ID=$(json_field /tmp/release.json id) echo "Release id=$RELEASE_ID" # 3. Upload l'APK en asset. From c5da77652c20bf3340ba2c4ee979edb43b00b57b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 03:26:11 +0100 Subject: [PATCH 027/294] ci(forgejo): limit json_field extraction to top-level keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'API Forgejo renvoie pour /releases/tags/ un objet JSON pretty-printed où l'id racine (release.id, ex. 10706) est sur la première ligne, mais l'objet author contient aussi un id (souvent 1 pour le premier user du repo). L'ancienne regex sed matchait la première occurrence globale de "id" dans le fichier, donc elle retombait sur author.id=1 et le PATCH /releases/1 tombait en 404 'The target couldn't be found'. Fix : on pipe le fichier dans 'head -3' pour ne matcher que les premières lignes (couvre largement le préambule de l'objet release). Si Forgejo renvoie du JSON minifié (une seule ligne), head -3 renvoie toute la ligne et la regex matche le premier id (la racine, parce que les champs auteur sont après les champs racine). --- .forgejo/workflows/release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 9306d087c..137148a2b 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -236,12 +236,16 @@ jobs: printf '%s' "$s" } - # Extraction d'un champ JSON scalaire (string ou number) depuis - # un fichier. Utilise sed basique, suffisant pour les champs - # id / tag_name que l'API renvoie en clair. + # Extraction d'un champ JSON scalaire de premier niveau depuis un + # fichier. On ne lit que les premières lignes pour éviter de + # matcher un champ homonyme dans un objet imbriqué (par ex. + # le champ "id" de l'auteur d'une release Forgejo, qui vaut + # typiquement 1 pour le premier user du repo). Sans cette + # restriction, le PATCH sur /releases/ tombe en + # 404 "The target couldn't be found". json_field() { local file="$1" field="$2" - sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([^\",}]*\)\"\?.*/\1/p" "$file" | head -1 + head -3 "$file" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([0-9][0-9]*\)\"\?.*/\1/p" | head -1 } # 1. Vérifier si la release existe déjà pour ce tag. From f93ade3136b6e80ab960422a601e8e58903164b4 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 04:35:00 +0100 Subject: [PATCH 028/294] ci(forgejo): build JSON bodies with jq instead of hand-rolled sed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir de debian12-dotnet10-android36-v2 (Dockerfile du repo dotnet-android-build-image, commit e06f096 "adds jq"). On en profite pour supprimer json_escape et json_field à base de sed, qui étaient fragiles : * sed est greedy par défaut : sur du JSON minifié d'une seule ligne (ce que renvoie l'API Forgejo de cette instance pour /releases/tags/), la regex s/.*"id".../\1/p attrape la DERNIÈRE occurrence de "id": sur la ligne, qui est l'id de l'auteur de la release (1, premier user du repo), pas l'id de la release (10706). * Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié : il n'isole rien et le sed greedy continue à capturer l'id de l'auteur. * PATCH /releases/1 tombait alors en 404 "The target couldn't be found" (cf. run échoué du 2026-08-17 04:05 sur le tag 1.0.6). jq résout les deux problèmes en une fois : * jq -r '.id' retourne le champ id racine, pas l'id imbriqué dans author. * jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease: $prerelease}' construit un body JSON proprement échappé (backslashes, guillemets, newlines, caractères de contrôle Unicode) sans avoir à le reproduire à la main. Effet de bord : les bodies PATCH et POST sont écrits dans /tmp/patch.json et /tmp/post.json puis passés à curl via --data-binary @ au lieu d'une variable shell. Plus de problème de quoting en chaîne shell, plus de collision avec les espaces ou les caractères spéciaux du body. Pré-requis côté runner : image pazof/yavsc-build-env:debian12- dotnet10-android36-v2 (avec jq) + maj du label correspondant dans la config du runner Forgejo. --- .forgejo/workflows/release.yml | 82 +++++++++++++++++----------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 137148a2b..cf3aaff7c 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -15,10 +15,16 @@ # the secret table). Bumping to Forgejo v16 should fix it; until then, # the runner-provided token keeps the workflow operational. # -# Why bash + curl, no third-party actions: the runner's docker label -# points at pazof/yavsc-build-env, a Debian image without Node.js. Any -# action like actions/checkout, rasterstate/forgejo-release-action, etc. -# fails with "executable file not found in $PATH". Same constraint as +# Why bash + jq + curl, no third-party actions: the runner's docker +# label points at pazof/yavsc-build-env, a Debian image with jq but +# without Node.js or python3. Any action like actions/checkout, +# rasterstate/forgejo-release-action, etc. fails with "executable +# file not found in $PATH". jq is shipped in the image from +# debian12-dotnet10-android36-v2 onward; earlier tags fell back to +# hand-rolled JSON building via sed, which was fragile (cf. PR #30: +# sed greedy + head -3 still matched author.id instead of the +# release id on the minified JSON this instance returns, PATCH +# /releases/1 → 404). Same constraint as # .forgejo/workflows/buildAndTest.yml. # # This workflow complements .github/workflows/docker-publish-android.yml @@ -222,31 +228,22 @@ jobs: API_BASE="${GITHUB_API_URL%/}" API_BASE="${API_BASE%/api/v1}" - # Pas de python3, pas de jq dans l'image runner. On génère - # le JSON à la main : escaping minimal des caractères - # spéciaux JSON dans les chaînes (\\, \", \n, \r, \t). - # Suffisant pour un CHANGELOG.md bien formé. - json_escape() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\"/\\\"}" - s="${s//$'\n'/\\n}" - s="${s//$'\r'/\\r}" - s="${s//$'\t'/\\t}" - printf '%s' "$s" - } - - # Extraction d'un champ JSON scalaire de premier niveau depuis un - # fichier. On ne lit que les premières lignes pour éviter de - # matcher un champ homonyme dans un objet imbriqué (par ex. - # le champ "id" de l'auteur d'une release Forgejo, qui vaut - # typiquement 1 pour le premier user du repo). Sans cette - # restriction, le PATCH sur /releases/ tombe en - # 404 "The target couldn't be found". - json_field() { - local file="$1" field="$2" - head -3 "$file" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([0-9][0-9]*\)\"\?.*/\1/p" | head -1 - } + # Construction des bodies JSON et extraction de champs via + # jq. L'image runner pazof/yavsc-build-env installe jq + # (>= 1.7) depuis debian12-dotnet10-android36-v2. La + # chaîne de construction --arg/--argjson garantit un + # escaping correct (backslashes, guillemets, newlines, + # caractères de contrôle Unicode) sans avoir à le + # reproduire à la main. + # + # json_escape et json_field à base de sed ont vécu : le + # sed greedy matche la dernière occurrence d'un champ + # dans la ligne, et l'API renvoie sur cette instance un + # JSON minifié d'une seule ligne où l'id de l'auteur + # (1, premier user du repo) suit l'id de la release + # (10706). PATCH /releases/ tombait + # alors en 404 "The target couldn't be found". jq + # résout les deux problèmes en une fois. # 1. Vérifier si la release existe déjà pour ce tag. echo "::group::Check existing release for tag $TAG" @@ -257,7 +254,7 @@ jobs: echo "GET releases/tags/$TAG -> HTTP $HTTP" EXISTING_ID="" if [[ "$HTTP" == "200" ]]; then - EXISTING_ID=$(json_field /tmp/existing.json id) + EXISTING_ID=$(jq -r '.id // empty' /tmp/existing.json) echo "Existing release id: ${EXISTING_ID:-none}" fi echo "::endgroup::" @@ -265,30 +262,35 @@ jobs: # 2. Créer ou mettre à jour la release. if [[ -n "$EXISTING_ID" ]]; then echo "::group::Update release id=$EXISTING_ID" - BODY=$(printf '{"body":"%s","prerelease":%s}' \ - "$(json_escape "$RELEASE_BODY")" "$IS_PRERELEASE") + jq -n \ + --arg body "$RELEASE_BODY" \ + --argjson prerelease "$IS_PRERELEASE" \ + '{body: $body, prerelease: $prerelease}' \ + > /tmp/patch.json HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X PATCH \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ - --data-binary "$BODY" \ + --data-binary @/tmp/patch.json \ "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID") echo "PATCH release -> HTTP $HTTP" echo "::endgroup::" else echo "::group::Create release" - BODY=$(printf '{"tag_name":"%s","name":"%s","body":"%s","prerelease":%s}' \ - "$(json_escape "$TAG")" \ - "$(json_escape "$TAG")" \ - "$(json_escape "$RELEASE_BODY")" \ - "$IS_PRERELEASE") + jq -n \ + --arg tag "$TAG" \ + --arg name "$TAG" \ + --arg body "$RELEASE_BODY" \ + --argjson prerelease "$IS_PRERELEASE" \ + '{tag_name: $tag, name: $name, body: $body, prerelease: $prerelease}' \ + > /tmp/post.json HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ -X POST \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ - --data-binary "$BODY" \ + --data-binary @/tmp/post.json \ "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases") echo "POST release -> HTTP $HTTP" echo "::endgroup::" @@ -300,7 +302,7 @@ jobs: exit 1 fi - RELEASE_ID=$(json_field /tmp/release.json id) + RELEASE_ID=$(jq -r '.id' /tmp/release.json) echo "Release id=$RELEASE_ID" # 3. Upload l'APK en asset. From c9c402eb91539f3adb3f91ee354cde597160e649 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 05:25:20 +0100 Subject: [PATCH 029/294] chore(release): update 1.0.6 CHANGELOG section (image v2, jq fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La section [1.0.6] - stable du CHANGELOG mentionnait encore debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH release qui tombait en 404 à cause du sed greedy + JSON minifié. Mets à jour avant de relancer la publication de la release 1.0.6 (workflow_dispatch), pour que le body publié reflète l'état réel de l'infra (image v2 avec jq) et du workflow. --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ec2b56e..c855b2490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,13 @@ pour la production des paquets `.deb`. ### Added - Self-hosted Forgejo Actions runner now drives the CI build for the yavsc repository, using the - `pazof/yavsc-build-env:debian12-dotnet10-android36-v1` image pulled + `pazof/yavsc-build-env:debian12-dotnet10-android36-v2` image pulled from Docker Hub. Workflow runs end-to-end: clone, restore, build, test, with NuGet.config picking up the `isn.pschneider.fr` feed. +- The build-env image now ships `jq` (Debian package, ≥ 1.7), so the + release workflow can build JSON bodies and parse API responses + without a hand-rolled `sed`-based extractor that was matching the + wrong `id` field on minified responses. ### Changed - CI workflow `.forgejo/workflows/buildAndTest.yml` no longer relies on @@ -47,6 +51,12 @@ pour la production des paquets `.deb`. Actions APK build (`--allow-insecure-connections` on an HTTPS endpoint, exit 1). `NuGet.config` at the repo root supplies the `isn.pschneider.fr` feed for every restore, including inside Docker. +- `.forgejo/workflows/release.yml`: PATCH on `/releases/{id}` no longer + 404s on existing releases. The previous `sed`-based `json_field` + matched the last `id` on the line (the author's), so it tried to + PATCH `/releases/1` (the first user of the instance) instead of the + actual release id. Switched to `jq` for both body construction and + field extraction. [Unreleased]: https://github.com/pazof/yavsc/compare/HEAD [1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 From 36789217ec6daa6a647a5567094ac8608c242e24 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 05:44:12 +0100 Subject: [PATCH 030/294] ci(forgejo): put asset name in URL query string, not as curl arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2) a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name' parameter". Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...` était passé en argument positionnel entre `--data-binary @file` et l'URL. curl l'interprète comme un second fichier d'input (un fichier nommé '?name=...'), pas comme un query param, et l'API Forgejo ne voit jamais le name. Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement. L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets. --- .forgejo/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index cf3aaff7c..ef518037a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -306,6 +306,11 @@ jobs: echo "Release id=$RELEASE_ID" # 3. Upload l'APK en asset. + # Le nom du fichier passe en query string (?name=...), pas + # en argument positionnel entre --data-binary et l'URL : + # sinon curl l'interprète comme un second fichier d'input + # (un fichier nommé '?name=PostIt.Android.apk') et l'API + # Forgejo renvoie 400 "Missing 'name' parameter". echo "::group::Upload APK asset" HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ -X POST \ @@ -313,8 +318,7 @@ jobs: -H "Content-Type: application/octet-stream" \ -H "Accept: application/json" \ --data-binary "@/src/_src/PostIt.Android.apk" \ - "?name=PostIt.Android.apk" \ - "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets") + "$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk") echo "POST asset -> HTTP $HTTP" echo "::endgroup::" From b0e220e1113e7781a297519e981091cbcc10062e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:34:48 +0100 Subject: [PATCH 031/294] refactor(blogacl): move BlogAcl + Circle controllers from Yavsc.Api to Yavsc.Blogs These two controllers belong to the Blogs subsystem (their routes /api/blogacl and /api/circle are blog-domain concerns, not the generic Api surface). Moving them next to BlogApiController keeps related code together and prepares the PostIt client to consume them through the same BlogsApiUrl base address as the existing BlogApiClient. Mechanical changes only: - Namespace Yavsc.Controllers -> Yavsc.Blogs.Controllers - Drop unused 'using Yavsc.Helpers;' (no symbol in the new compilation unit depends on it; the build confirms it was dead since the controllers were first written) - Fix typo in CircleApiController route: 'api/cirle' -> 'api/circle' (any client trying to call the documented route was hitting 404) No functional changes to authorization or query shape. The known security gaps in these controllers (GetBlogACL and GetCircle return unfiltered collections, DeleteCircle has no ownership check) are deliberately left untouched in this commit and will be addressed in a follow-up. --- .../Controllers}/BlogAclApiController.cs | 5 ++--- .../Controllers}/CircleApiController.cs | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) rename src/{Yavsc.Api/Controllers/Relationship => Yavsc.Blogs/Controllers}/BlogAclApiController.cs (98%) rename src/{Yavsc.Api/Controllers/Relationship => Yavsc.Blogs/Controllers}/CircleApiController.cs (98%) diff --git a/src/Yavsc.Api/Controllers/Relationship/BlogAclApiController.cs b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs similarity index 98% rename from src/Yavsc.Api/Controllers/Relationship/BlogAclApiController.cs rename to src/Yavsc.Blogs/Controllers/BlogAclApiController.cs index 6e8b905c8..3fbd89cf2 100644 --- a/src/Yavsc.Api/Controllers/Relationship/BlogAclApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs @@ -1,12 +1,11 @@ using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Models.Access; using Yavsc.Server.Helpers; -namespace Yavsc.Controllers +namespace Yavsc.Blogs.Controllers { [Produces("application/json")] [Route("api/blogacl")] @@ -86,7 +85,7 @@ namespace Yavsc.Controllers } private bool CheckOwner (long circleId) { - + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var circle = _context.Circle.First(c=>c.Id==circleId); _context.Entry(circle).State = EntityState.Detached; diff --git a/src/Yavsc.Api/Controllers/Relationship/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs similarity index 98% rename from src/Yavsc.Api/Controllers/Relationship/CircleApiController.cs rename to src/Yavsc.Blogs/Controllers/CircleApiController.cs index 7a8b4debb..b5434f830 100644 --- a/src/Yavsc.Api/Controllers/Relationship/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -1,14 +1,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Models.Relationship; using Yavsc.Server.Helpers; -namespace Yavsc.Controllers +namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route("api/cirle")] + [Route("api/circle")] public class CircleApiController : Controller { private readonly ApplicationDbContext _context; From f7d090833d89965bbf4489d041e40816b7ef3678 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:36:20 +0100 Subject: [PATCH 032/294] fix(blogacl): restrict Circle + BlogAcl reads and writes to caller's own data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the data-leak holes that survived the move of these controllers from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its membership should never be visible, modifiable, or deletable by anyone other than its owner. BlogAclApiController: - GetBlogACL() was returning the full table; now filters by Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed) so EF Core can push the filter into SQL instead of materialising the whole table. - Other endpoints (GetById, Put, Post, Delete) already enforced ownership; left as is. CircleApiController: - GetCircle() (no id) now filters by OwnerId. - GetCircle(id) now requires c.Id == id && c.OwnerId == uid; returns 404 (not 403) on miss to avoid leaking the existence of someone else's circle. - PutCircle verifies the existing record is owned by the caller, then forces circle.OwnerId = uid on the body (the client's value is ignored). Returns ChallengeResult when the caller doesn't own the record. - PostCircle forces circle.OwnerId = uid (was trusting the body). - DeleteCircle now filters by OwnerId; 404 on miss. All checks use the same source of truth (User.FindFirstValue( ClaimTypes.NameIdentifier)) that the existing BlogAclApiController authz code already relies on. --- .../Controllers/BlogAclApiController.cs | 13 ++++- .../Controllers/CircleApiController.cs | 58 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs index 3fbd89cf2..aa81f9d5a 100644 --- a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,11 +19,19 @@ namespace Yavsc.Blogs.Controllers _context = context; } - // GET: api/BlogAclApi + /// + /// Returns the ACL entries for the caller's own blog posts. + /// Blog posts (and therefore their ACLs) are private to their + /// author — the API never exposes another user's ACL. + /// + // GET: api/blogacl [HttpGet] public IEnumerable GetBlogACL() { - return _context.CircleAuthorizationToBlogPost; + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + return _context.CircleAuthorizationToBlogPost + .Include(a => a.Allowed) + .Where(a => a.Allowed.OwnerId == uid); } // GET: api/BlogAclApi/5 diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index b5434f830..368b488aa 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -1,3 +1,5 @@ +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Yavsc.Models; @@ -17,14 +19,22 @@ namespace Yavsc.Blogs.Controllers _context = context; } - // GET: api/CircleApi + /// + /// Returns the caller's own circles. Circles are personal — + /// the API never exposes another user's circles, even by id. + /// + // GET: api/circle [HttpGet] public IEnumerable GetCircle() { - return _context.Circle; + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + return _context.Circle.Where(c => c.OwnerId == uid); } - // GET: api/CircleApi/5 + /// + /// Returns a single circle only when it belongs to the caller. + /// + // GET: api/circle/5 [HttpGet("{id}", Name = "GetCircle")] public async Task GetCircle([FromRoute] long id) { @@ -33,7 +43,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - Circle circle = await _context.Circle.SingleAsync(m => m.Id == id); + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + Circle circle = await _context.Circle.SingleOrDefaultAsync( + m => m.Id == id && m.OwnerId == uid); if (circle == null) { @@ -43,7 +55,12 @@ namespace Yavsc.Blogs.Controllers return Ok(circle); } - // PUT: api/CircleApi/5 + /// + /// Replaces a circle. The caller must own it; the server + /// reasserts ownership regardless of any OwnerId the client + /// tries to put in the body. + /// + // PUT: api/circle/5 [HttpPut("{id}")] public async Task PutCircle([FromRoute] long id, [FromBody] Circle circle) { @@ -57,6 +74,16 @@ namespace Yavsc.Blogs.Controllers return BadRequest(); } + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + var existing = await _context.Circle.SingleOrDefaultAsync( + c => c.Id == id && c.OwnerId == uid); + if (existing is null) + { + return new ChallengeResult(); + } + + // Force OwnerId to the caller; the body value is ignored. + circle.OwnerId = uid; _context.Entry(circle).State = EntityState.Modified; try @@ -78,7 +105,11 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - // POST: api/CircleApi + /// + /// Creates a circle owned by the caller. The server overwrites + /// any OwnerId the client sends in the body. + /// + // POST: api/circle [HttpPost] public async Task PostCircle([FromBody] Circle circle) { @@ -87,6 +118,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + circle.OwnerId = uid; + _context.Circle.Add(circle); try { @@ -107,7 +141,13 @@ namespace Yavsc.Blogs.Controllers return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle); } - // DELETE: api/CircleApi/5 + /// + /// Deletes a circle only if the caller owns it. Returns 404 + /// (not 403) when the circle does not exist or is not owned + /// by the caller, to avoid leaking the existence of someone + /// else's circle. + /// + // DELETE: api/circle/5 [HttpDelete("{id}")] public async Task DeleteCircle([FromRoute] long id) { @@ -116,7 +156,9 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - Circle circle = await _context.Circle.SingleAsync(m => m.Id == id); + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); + Circle circle = await _context.Circle.SingleOrDefaultAsync( + m => m.Id == id && m.OwnerId == uid); if (circle == null) { return NotFound(); From d14f06bc3e2cc49e9b9d8526f20314a51e38f67b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:45:45 +0100 Subject: [PATCH 033/294] refactor(model): move BlogPost DTO from PostIt.Models to Yavsc.Blogspot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlogPost is shared between the server (Yavsc.Server/Models/Blog/ BlogPost.cs is the EF entity) and any client that talks to the blogs API. Keeping the client-side DTO in PostIt.Models made sense when there was only one consumer; now that the Yavsc.Api.Client project is about to host BlogApiClient alongside CircleApiClient and BlogAclApiClient, the DTO has to live in a layer both the client project and PostIt can reference without inverting the dependency. Yavsc.Abstract is the existing home for cross-tier interfaces and DTOs (IBlogPost, IBlogPostPayLoad, IApplicationUser). Yavsc.Blogspot is the sub-namespace already used by the matching interface, so the new concrete class follows. Why not move Circle and CircleAuthorizationToBlogPost at the same time? Both depend on the concrete ApplicationUser class (via the Owner and Target/Allowed navigation properties) which lives in Yavsc.Server. Moving them would mean either dragging ApplicationUser into the abstract layer (huge blast radius — auth, billing, chat, etc.) or weakening the navigation properties (breaks EF Core shaping). They're staying where they are; the new Yavsc.Api.Client will get DTO counterparts instead. Updated call sites: - 4 .cs files: replace 'using PostIt.Models;' with 'using Yavsc.Blogspot;' where the file was actually using BlogPost. Files that only used SignaturePadData keep their 'using PostIt.Models;' — that type stays put. - 1 .axaml file: xmlns:models="using:PostIt.Models" -> xmlns:models="using:Yavsc.Blogspot" (one DataTemplate for the post list in MainPage). Build + tests green (51/51). --- src/PostIt.Tests/BlogApiTestFakes.cs | 2 +- src/PostIt.Tests/MainPageSaveTests.cs | 2 +- src/PostIt.Tests/PostItViewModelTests.cs | 2 +- src/PostIt/PostIt/Services/BlogApiClient.cs | 2 +- src/PostIt/PostIt/ViewModels/MainPageViewModel.cs | 2 +- src/PostIt/PostIt/Views/MainPage.axaml | 2 +- .../Blogspot}/BlogPost.cs | 15 +++++++-------- 7 files changed, 13 insertions(+), 14 deletions(-) rename src/{PostIt/PostIt/Models => Yavsc.Abstract/Blogspot}/BlogPost.cs (65%) diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs index 755ce105d..3f8b98b07 100644 --- a/src/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt.Tests/BlogApiTestFakes.cs @@ -1,4 +1,4 @@ -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using Yavsc.Models; diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index c76115d73..cea3e83f3 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -2,7 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index 48569915c..d8de8025c 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,4 +1,4 @@ -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs index 5e927b974..f20619277 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using PostIt.Models; +using Yavsc.Blogspot; namespace PostIt.Services; diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e7ea26a02..e8024d7d1 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using PostIt.Models; +using Yavsc.Blogspot; using PostIt.Services; namespace PostIt.ViewModels; diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 5c42e27c2..1ab8d9053 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:PostIt.ViewModels" - xmlns:models="using:PostIt.Models" + xmlns:models="using:Yavsc.Blogspot" xmlns:views="using:PostIt.Views" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" mc:Ignorable="d" diff --git a/src/PostIt/PostIt/Models/BlogPost.cs b/src/Yavsc.Abstract/Blogspot/BlogPost.cs similarity index 65% rename from src/PostIt/PostIt/Models/BlogPost.cs rename to src/Yavsc.Abstract/Blogspot/BlogPost.cs index e62fcea2e..854aa29f9 100644 --- a/src/PostIt/PostIt/Models/BlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/BlogPost.cs @@ -1,9 +1,8 @@ using System; using Yavsc.Abstract.Identity; using Yavsc.Abstract.Identity.Security; -using Yavsc.Blogspot; -namespace PostIt.Models; +namespace Yavsc.Blogspot; public class BlogPost : IBlogPost { @@ -13,12 +12,12 @@ public class BlogPost : IBlogPost public string Article { get; set ; } public string Photo { get; set ; } - public long Id { get; set ; } - public DateTime DateCreated { get; set ; } - public string UserCreated { get; set ; } - public DateTime DateModified { get; set ; } - public string UserModified { get; set ; } - public string Title { get; set ; } + public long Id { get; set; } + public DateTime DateCreated { get; set; } + public string UserCreated { get; set; } + public DateTime DateModified { get; set; } + public string UserModified { get; set; } + public string Title { get; set; } public bool AuthorizeCircle(long circleId) { From 6ca8191462d55ae3771ca3a239c47c7700ebd18b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:50:24 +0100 Subject: [PATCH 034/294] refactor(api-client): introduce IYavscApiClient abstraction in Yavsc.Api.Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yavsc.Api.Client is the new home for high-level HTTP clients (BlogApiClient, CircleApiClient, BlogAclApiClient, etc.). It depends on the host application's transport layer, but the host (PostIt) is a UI app with OIDC, settings, and an ApplicationData directory — none of which the abstract client library should know about. The IYavscApiClient interface captures just the transport surface those clients need: - HttpClient (so the client can configure BaseAddress) - CallAsync and CallAsync (the JSON over HTTP verb) It deliberately leaves out LoginAsync / TrySilentLoginAsync / CurrentAccessToken / HasValidSession / Settings — those are authentication and configuration concerns, not transport. They stay on the concrete YavscApiClient in PostIt.Services. The concrete YavscApiClient now implements IYavscApiClient; the existing public surface is unchanged (no breaking changes for existing call sites in PostIt or the tests). This commit only lays the foundation. The actual high-level clients (Blog/Circle/BlogAcl) land in a follow-up commit that re-uses this interface, so this one stays a small, reviewable refactor. --- src/PostIt/PostIt/PostIt.csproj | 1 + src/PostIt/PostIt/Services/YavscApiClient.cs | 3 +- src/Yavsc.Api.Client/IYavscApiClient.cs | 62 ++++++++++++++++++++ src/Yavsc.Api.Client/Yavsc.Api.Client.csproj | 29 +++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/Yavsc.Api.Client/IYavscApiClient.cs create mode 100644 src/Yavsc.Api.Client/Yavsc.Api.Client.csproj diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index d9cf96d38..e4d51a88e 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -25,6 +25,7 @@ + diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 9ae1453b3..b611fe02b 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using IdentityModel.OidcClient; using PostIt.ViewModels; +using Yavsc.Api.Client; namespace PostIt.Services; @@ -24,7 +25,7 @@ namespace PostIt.Services; /// only refreshes once even if many /// concurrent requests are in flight. /// -public class YavscApiClient : IAsyncDisposable +public class YavscApiClient : IYavscApiClient, IAsyncDisposable { // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. diff --git a/src/Yavsc.Api.Client/IYavscApiClient.cs b/src/Yavsc.Api.Client/IYavscApiClient.cs new file mode 100644 index 000000000..209ec07d7 --- /dev/null +++ b/src/Yavsc.Api.Client/IYavscApiClient.cs @@ -0,0 +1,62 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Yavsc.Api.Client; + +/// +/// Transport surface that the high-level clients +/// (, , +/// ) need to do their work. +/// +/// This is intentionally a thin, transport-only contract. It +/// does not include the OIDC login / refresh / logout surface — +/// that lives on the concrete YavscApiClient in the +/// consuming application and is wired by the application +/// composition root. Splitting the two keeps Yavsc.Api.Client +/// usable from any host (a CLI, a unit test, a future iOS +/// client) without dragging OIDC, identity, and a Settings +/// POMVO everywhere. +/// +/// Implementations are expected to: +/// +/// Attach a Bearer access token to every outbound request. +/// Silently refresh the token on a 401 and retry once. +/// Serialise the request body as JSON and deserialise the +/// response body with case-insensitive property matching. +/// +/// +/// The exception contract on non-2xx responses is +/// with a message that includes +/// the response body (capped), so callers can surface the +/// server-side validation problem to the UI without losing +/// context. +/// +public interface IYavscApiClient : IAsyncDisposable +{ + /// + /// The configured . Clients set its + /// BaseAddress in their constructors to point at the + /// API host they target. + /// + HttpClient Http { get; } + + /// Call a JSON endpoint with a typed return value. + /// HTTP verb. + /// Path relative to . + /// Optional request body, serialised as JSON. + /// Cancellation token. + Task CallAsync( + HttpMethod method, + string path, + object? body = null, + CancellationToken ct = default); + + /// Call a JSON endpoint that returns no useful body (DELETE, 204, etc.). + Task CallAsync( + HttpMethod method, + string path, + object? body = null, + CancellationToken ct = default); +} diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj new file mode 100644 index 000000000..5376856dd --- /dev/null +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + Yavsc.Api.Client + Yavsc.Api.Client + enable + latest + true + + Thin HTTP clients for the Yavsc API. Each client is a DTO↔path + mapper; all transport concerns (base URL, JSON, Bearer auth, + silent refresh on 401) are delegated to YavscApiClient, which + lives in the consuming application (PostIt). + + https://github.com/pazof/yavsc + true + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 + + + + + + + + From 46de7b34478cf53693b0a1dfd37d17bfbc1c30aa Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:50:35 +0100 Subject: [PATCH 035/294] feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients Creates the high-level HTTP client library the PostIt UI will consume to manage blog posts, circles, and per-post ACLs. Clients in this commit: - BlogApiClient (moved from PostIt/Services; same public surface, now depends on IYavscApiClient instead of the concrete class). - CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes the blogs base URL explicitly in its constructor so it doesn't need to know about PostIt's Settings type. - BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl. Same conventions as CircleApiClient. DTOs (Yavsc.Api.Client.Dtos): - CircleDto: id, name, ownerId, public. Stops short of the navigation properties on the server-side Circle (Owner, Members), which depend on ApplicationUser and other server types we don't want to drag into the client. - CircleAuthorizationDto: circleId, blogPostId, comment. Same reason: the server entity has Target and Allowed navigation properties the client never needs. The clients now require the caller to pass the blogs base URL explicitly in the constructor (previously the BlogApiClient sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that field is PostIt-specific). The one production call site (App.axaml.cs) and four test call sites are updated to pass the URL. Build + 51/51 tests green. The IYavscApiClient abstraction was landed in the previous commit so this one could be a pure addition + relocation. --- src/PostIt.Tests/BearerScopeTests.cs | 5 +- src/PostIt.Tests/MainPageSaveTests.cs | 3 +- src/PostIt.Tests/PostItViewModelTests.cs | 5 +- src/PostIt.Tests/YavscApiClientTests.cs | 3 ++ src/PostIt/PostIt/App.axaml.cs | 3 +- .../PostIt/ViewModels/MainPageViewModel.cs | 1 + src/Yavsc.Api.Client/BlogAclApiClient.cs | 49 +++++++++++++++++ .../BlogApiClient.cs | 19 ++++--- src/Yavsc.Api.Client/CircleApiClient.cs | 53 +++++++++++++++++++ .../Dtos/CircleAuthorizationDto.cs | 19 +++++++ src/Yavsc.Api.Client/Dtos/CircleDto.cs | 23 ++++++++ 11 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 src/Yavsc.Api.Client/BlogAclApiClient.cs rename src/{PostIt/PostIt/Services => Yavsc.Api.Client}/BlogApiClient.cs (78%) create mode 100644 src/Yavsc.Api.Client/CircleApiClient.cs create mode 100644 src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs create mode 100644 src/Yavsc.Api.Client/Dtos/CircleDto.cs diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs index 68fa514e5..1f47a176c 100644 --- a/src/PostIt.Tests/BearerScopeTests.cs +++ b/src/PostIt.Tests/BearerScopeTests.cs @@ -8,6 +8,9 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.Services; using PostIt.Services; using Xunit; @@ -119,7 +122,7 @@ public class BearerScopeTests // Resolve a BlogApiClient on top. We don't need real // posts; we just need the outbound HTTP request to be // the one we capture. - var blog = new BlogApiClient(subClient); + var blog = new BlogApiClient(subClient, "http://localhost/"); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs index cea3e83f3..350a71f1e 100644 --- a/src/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt.Tests/MainPageSaveTests.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Headless.XUnit; using Avalonia.VisualTree; using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; @@ -40,7 +41,7 @@ public class MainPageSaveTests // not a Control, so it needs a navigation host). var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); - var blog = new BlogApiClient(api); + var blog = new BlogApiClient(api, "http://localhost/"); var viewModel = new MainPageViewModel(blog); var page = new MainPage { DataContext = viewModel }; diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index d8de8025c..b964a18e0 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -1,4 +1,5 @@ using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; using PostIt.ViewModels; @@ -14,7 +15,7 @@ public class PostItViewModelTests // default; tests construct one with a fake YavscApiClient that // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); - var blog = new BlogApiClient(fakeApi); + var blog = new BlogApiClient(fakeApi, "http://localhost/"); var viewModel = new MainPageViewModel(blog); viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); @@ -46,7 +47,7 @@ public class PostItViewModelTests new() { Id = 2, Title = "World" } }; var api = new StubYavscApiClient(expected); - var blog = new BlogApiClient(api); + var blog = new BlogApiClient(api, "http://localhost/"); var posts = await blog.GetPostsAsync(); diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index c020fec9f..e54bc5411 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -8,6 +8,9 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.Services; using System.Threading.Tasks; using IdentityModel.OidcClient; using IdentityModel.OidcClient.Browser; diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b5740f2fb..4a250ebee 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; using PostIt.Services; +using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; @@ -55,7 +56,7 @@ public partial class App : Application "PostIt", "tokens.json")); var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api); + var client = new BlogApiClient(api, settings.BlogsApiUrl); var services = new ServiceCollection(); diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index e8024d7d1..a9864db02 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Yavsc.Blogspot; +using Yavsc.Api.Client; using PostIt.Services; namespace PostIt.ViewModels; diff --git a/src/Yavsc.Api.Client/BlogAclApiClient.cs b/src/Yavsc.Api.Client/BlogAclApiClient.cs new file mode 100644 index 000000000..71263ca4c --- /dev/null +++ b/src/Yavsc.Api.Client/BlogAclApiClient.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client.Dtos; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for /api/blogacl on the Yavsc Blogs server. +/// +/// Each grants a single +/// Circle access to a single BlogPost. The server +/// scopes every endpoint to the caller's uid: only the author of +/// the underlying blog post can list, create, modify, or delete +/// its ACL entries. +/// +public sealed class BlogAclApiClient +{ + private const string Path = "blogacl"; + + private readonly IYavscApiClient _api; + + public BlogAclApiClient(IYavscApiClient api, string blogsBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); + + if (api.Http.BaseAddress is null) + api.Http.BaseAddress = new Uri(blogsBaseAddress); + } + + public Task> GetMyAclAsync(CancellationToken ct = default) + => _api.CallAsync>(HttpMethod.Get, Path, ct: ct); + + public Task GetAclAsync(long circleId, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{Path}/{circleId}", ct: ct); + + public Task GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, Path, body: acl, ct: ct); + + public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct); + + public Task RevokeAsync(long circleId, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Delete, $"{Path}/{circleId}", ct: ct); +} diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs similarity index 78% rename from src/PostIt/PostIt/Services/BlogApiClient.cs rename to src/Yavsc.Api.Client/BlogApiClient.cs index f20619277..537124a72 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/Yavsc.Api.Client/BlogApiClient.cs @@ -5,15 +5,16 @@ using System.Threading; using System.Threading.Tasks; using Yavsc.Blogspot; -namespace PostIt.Services; +namespace Yavsc.Api.Client; /// /// High-level client for the Blog subsystem of the Yavsc API /// (deployed at https://blogs.pschneider.fr). All transport /// concerns — base URL, JSON serialisation, Bearer auth, silent /// refresh on 401, request body shaping — are delegated to -/// . This class is a thin DTO↔path -/// mapper, nothing more. +/// , which lives in the consuming +/// application (PostIt). This class is a thin DTO↔path mapper, +/// nothing more. /// /// URL convention. 's /// BaseAddress already terminates with /api/v1/ @@ -34,16 +35,20 @@ public sealed class BlogApiClient { private const string DefaultPathPrefix = "blog"; - private readonly YavscApiClient _api; + private readonly IYavscApiClient _api; + private readonly Uri _baseAddress; private readonly string _pathPrefix; - public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix) + public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix) { _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); - // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the + // e.g. "https://blogs.pschneider.fr/api/v1/" — keep the // trailing slash so relative paths ("posts") resolve correctly. - api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl); + _baseAddress = new Uri(blogsBaseAddress); + api.Http.BaseAddress = _baseAddress; _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; } diff --git a/src/Yavsc.Api.Client/CircleApiClient.cs b/src/Yavsc.Api.Client/CircleApiClient.cs new file mode 100644 index 000000000..a8b04a40f --- /dev/null +++ b/src/Yavsc.Api.Client/CircleApiClient.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yavsc.Api.Client.Dtos; + +namespace Yavsc.Api.Client; + +/// +/// HTTP client for /api/circle on the Yavsc Blogs server. +/// +/// Same conventions as : all +/// transport is delegated to ; this +/// class only maps paths to DTOs. +/// +/// The server now (since the BlogAcl fix on this branch) +/// scopes every read and write to the caller's uid. There is no +/// way for the client to read or modify another user's circles +/// — the route will return 404 (not 403) when the circle exists +/// but belongs to someone else, to avoid leaking its existence. +/// +public sealed class CircleApiClient +{ + private const string Path = "circle"; + + private readonly IYavscApiClient _api; + + public CircleApiClient(IYavscApiClient api, string blogsBaseAddress) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + if (string.IsNullOrEmpty(blogsBaseAddress)) + throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress)); + + if (api.Http.BaseAddress is null) + api.Http.BaseAddress = new Uri(blogsBaseAddress); + } + + public Task> GetMyCirclesAsync(CancellationToken ct = default) + => _api.CallAsync>(HttpMethod.Get, Path, ct: ct); + + public Task GetCircleAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{Path}/{id}", ct: ct); + + public Task CreateCircleAsync(CircleDto circle, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, Path, body: circle, ct: ct); + + public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct); + + public Task DeleteCircleAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct); +} diff --git a/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs b/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs new file mode 100644 index 000000000..f5d1e50ea --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/CircleAuthorizationDto.cs @@ -0,0 +1,19 @@ +namespace Yavsc.Api.Client.Dtos; + +/// +/// Wire format for GET /api/blogacl and friends. +/// +/// The server-side +/// Yavsc.Models.Access.CircleAuthorizationToBlogPost EF entity +/// carries virtual navigation properties (Target, +/// Allowed) that pull in the full BlogPost and Circle graphs. +/// The client never needs them: when showing the ACL of a post, the +/// UI already has the post, and the circles are looked up by id +/// against the list returned by GET /api/circle. +/// +public sealed class CircleAuthorizationDto +{ + public long CircleId { get; set; } + public long BlogPostId { get; set; } + public bool Comment { get; set; } +} diff --git a/src/Yavsc.Api.Client/Dtos/CircleDto.cs b/src/Yavsc.Api.Client/Dtos/CircleDto.cs new file mode 100644 index 000000000..ed6980e20 --- /dev/null +++ b/src/Yavsc.Api.Client/Dtos/CircleDto.cs @@ -0,0 +1,23 @@ +namespace Yavsc.Api.Client.Dtos; + +/// +/// Wire format for GET /api/circle and friends. +/// +/// Field names match the JSON the server emits (camelCase via +/// the default policy), so no +/// [JsonPropertyName] attributes are required. +/// +/// Mirrors the server-side Yavsc.Models.Relationship.Circle +/// EF entity but stops short of the navigation properties +/// (Owner, Members) which depend on +/// ApplicationUser and other server-only types. The client +/// only ever needs the id, name, and owner of a circle to drive +/// the UI. +/// +public sealed class CircleDto +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public string OwnerId { get; set; } = string.Empty; + public bool Public { get; set; } +} From f6ddbfbaa03d3e65de9d89b8d0b14840d8b493c8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 17 Aug 2026 23:51:51 +0100 Subject: [PATCH 036/294] feat(postit): wire Circle + BlogAcl clients in the DI container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.axaml.cs is the composition root for PostIt. It now also builds and registers: - CircleApiClient (singleton) — backed by the same YavscApiClient and the same blogs base URL as BlogApiClient - BlogAclApiClient (singleton) — same shape - IYavscApiClient -> YavscApiClient mapping (singleton). The concrete class is still resolvable as YavscApiClient; the new registration makes the same instance available as IYavscApiClient so future consumers (and unit tests) can take the interface without coupling to the concrete type. The 3 high-level clients are singletons: they hold no mutable state of their own, just a reference to YavscApiClient and a base URL. Reusing the same instance across requests is what the HttpClient inside YavscApiClient was already designed for. --- src/PostIt/PostIt/App.axaml.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 4a250ebee..033dbd096 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -57,6 +57,8 @@ public partial class App : Application var api = new YavscApiClient(settings, tokenStore); var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var services = new ServiceCollection(); @@ -79,8 +81,11 @@ public partial class App : Application // ViewModels services.AddSingleton(settings); - services.AddSingleton(api); + services.AddSingleton(api); + services.AddSingleton(api); services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); services.AddTransient(); services.AddTransient(); services.AddTransient(); From b86ea031b95b6855a27dc6f90767afb69c17b243 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 18 Aug 2026 00:06:57 +0100 Subject: [PATCH 037/294] feat(postit): UI for managing Circles + per-post ACL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing the user-facing surface for the BlogAcl work. The user can now: 1. Open the 'Mes cercles' page (a new 'Mes cercles' button on the main page) and create / edit / delete their own circles. The page lists circles in an ObservableCollection bound to a ListBox; per-row buttons drive StartEdit and Delete; the bottom editor pushes new / edited circles via the Save command. 2. With a post selected, click the new 'ACL' button to open a modal 'PostAclDialog' for that post. The modal shows the current ACL entries (filtered server-side by Allowed.OwnerId == caller) and a dropdown of the caller's circles to add. Each entry has a 'Revoke' button. Both pages follow the same pattern: - ViewModel uses [ObservableProperty] for state and [RelayCommand] for verbs; IsBusy drives a ProgressBar overlay; StatusMessage surfaces server feedback. - View follows the XAML-Background/Foreground lesson (no hard-coded colours), so dark mode works without contrast surprises. - Code-behind is minimal — just AvaloniaXamlLoader.Load — because navigation is driven by RelayCommand + event (ManageAclRequested, OpenCirclesRequested) that the MainPage code-behind handles via its DataContextChanged handler. The 'complete' scope (c) of this commit was confirmed by Paul. Three follow-up tracks are deliberately out of scope and tracked in MEMORY.md (2026-08-18): - i18n: no .resx / IStringLocalizer today; all visible text is hard-coded French. - Avalonia.Headless UI tests: only ViewModel-level coverage is feasible today; full navigation tests are a separate effort. - XAML accessibility audit of pre-existing pages (Settings, MainPage) that predate the Background/Foreground lesson. Build + 51/51 tests green. --- src/PostIt/PostIt/App.axaml.cs | 2 + .../PostIt/ViewModels/CirclesPageViewModel.cs | 155 +++++++++++++++++ .../PostIt/ViewModels/MainPageViewModel.cs | 28 ++++ .../ViewModels/PostAclDialogViewModel.cs | 157 ++++++++++++++++++ src/PostIt/PostIt/Views/CirclesPage.axaml | 66 ++++++++ src/PostIt/PostIt/Views/CirclesPage.axaml.cs | 18 ++ src/PostIt/PostIt/Views/MainPage.axaml | 2 + src/PostIt/PostIt/Views/MainPage.axaml.cs | 52 ++++++ src/PostIt/PostIt/Views/PostAclDialog.axaml | 65 ++++++++ .../PostIt/Views/PostAclDialog.axaml.cs | 54 ++++++ 10 files changed, 599 insertions(+) create mode 100644 src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs create mode 100644 src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs create mode 100644 src/PostIt/PostIt/Views/CirclesPage.axaml create mode 100644 src/PostIt/PostIt/Views/CirclesPage.axaml.cs create mode 100644 src/PostIt/PostIt/Views/PostAclDialog.axaml create mode 100644 src/PostIt/PostIt/Views/PostAclDialog.axaml.cs diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 033dbd096..e59e0d337 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -78,6 +78,7 @@ public partial class App : Application services.AddSingleton(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -89,6 +90,7 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Persistent session banner: one instance for the lifetime of // the app so the same VM survives page navigation. diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs new file mode 100644 index 000000000..17d4c4be4 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; + +namespace PostIt.ViewModels; + +/// +/// View model for the "Mes cercles" page. CRUD on the caller's own +/// circles (the server scopes every endpoint to the caller's uid +/// since the BlogAcl fix on this branch). +/// +/// The view lists circles in , supports +/// create / edit via , and exposes +/// per-item Delete and per-item edit commands. +/// drives a progress overlay during API calls; +/// surfaces success / error feedback in the view footer. +/// +public partial class CirclesPageViewModel : ViewModelBase +{ + private readonly CircleApiClient _client; + + [ObservableProperty] + public partial ObservableCollection Circles { get; set; } = new(); + + [ObservableProperty] + public partial CircleDto? SelectedCircle { get; set; } + + /// Editor buffer for the new / edited circle's name. + [ObservableProperty] + public partial string DraftName { get; set; } = string.Empty; + + /// Editor buffer for the new / edited circle's visibility flag. + [ObservableProperty] + public partial bool DraftPublic { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + public CirclesPageViewModel(CircleApiClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + + [RelayCommand] + public async Task RefreshAsync() + { + IsBusy = true; + try + { + var list = await _client.GetMyCirclesAsync(); + Circles = new ObservableCollection(list ?? new()); + StatusMessage = $"{Circles.Count} cercle(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public void StartCreate() + { + SelectedCircle = null; + DraftName = string.Empty; + DraftPublic = false; + StatusMessage = "Nouveau cercle"; + } + + [RelayCommand] + public void StartEdit(CircleDto? circle) + { + if (circle is null) return; + SelectedCircle = circle; + DraftName = circle.Name; + DraftPublic = circle.Public; + StatusMessage = $"Édition de « {circle.Name} »"; + } + + [RelayCommand] + public async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(DraftName)) + { + StatusMessage = "Le nom est obligatoire"; + return; + } + + IsBusy = true; + try + { + if (SelectedCircle is null) + { + var created = await _client.CreateCircleAsync(new CircleDto + { + Name = DraftName.Trim(), + Public = DraftPublic, + }); + StatusMessage = created is null + ? "Création échouée" + : $"Cercle « {created.Name} » créé"; + } + else + { + SelectedCircle.Name = DraftName.Trim(); + SelectedCircle.Public = DraftPublic; + await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle); + StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour"; + } + await RefreshAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task DeleteAsync(CircleDto? circle) + { + if (circle is null) return; + IsBusy = true; + try + { + await _client.DeleteCircleAsync(circle.Id); + StatusMessage = $"Cercle « {circle.Name} » supprimé"; + await RefreshAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index a9864db02..ddf6a732b 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -317,4 +317,32 @@ public partial class MainPageViewModel : ViewModelBase /// forced the buggy "draft with empty title" branch. private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle); private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; + + /// + /// Raised when the user asks to open the "manage ACL" dialog for + /// the currently selected post. The MainPage code-behind + /// listens to this event and pushes a PostAclDialog on the + /// navigation stack. The VM itself can't navigate directly + /// because the navigation surface (NavigationPage) lives + /// in the View layer. + /// + public event EventHandler? ManageAclRequested; + + [RelayCommand(CanExecute = nameof(CanManageAcl))] + public void ManageAcl() + { + if (SelectedPost is null) return; + ManageAclRequested?.Invoke(this, SelectedPost); + } + + /// + /// Raised when the user asks to open the circles page (full + /// CRUD on their own circles). Same routing as + /// . + /// + public event EventHandler? OpenCirclesRequested; + + [RelayCommand] + public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); } diff --git a/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs new file mode 100644 index 000000000..476a6b9af --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/PostAclDialogViewModel.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using Yavsc.Api.Client.Dtos; + +namespace PostIt.ViewModels; + +/// +/// View model for the "Gérer l'ACL" modal of a single blog post. +/// +/// Loads the caller's circles once on construct (the dropdown +/// only shows circles the user owns), then keeps an in-memory list +/// of the ACL entries for the post. / +/// are the only mutating verbs; both +/// refresh the list afterwards so the UI stays in sync with the +/// server. +/// +/// The server is the source of truth: it scopes every +/// endpoint to the caller's uid and rejects ACL grants on posts +/// the caller doesn't own. This VM does not re-validate that — +/// any 403 / 404 will surface as an exception caught by the +/// command and routed to . +/// +public partial class PostAclDialogViewModel : ViewModelBase +{ + private readonly BlogAclApiClient _aclClient; + private readonly CircleApiClient _circleClient; + + /// The post whose ACL is being edited. Set by the + /// caller (MainPage) when opening the dialog. + public BlogPost Post { get; } + + [ObservableProperty] + public partial ObservableCollection MyCircles { get; set; } = new(); + + [ObservableProperty] + public partial ObservableCollection AclEntries { get; set; } = new(); + + [ObservableProperty] + public partial CircleDto? SelectedCircleToAdd { get; set; } + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = string.Empty; + + public PostAclDialogViewModel( + BlogPost post, + BlogAclApiClient aclClient, + CircleApiClient circleClient) + { + Post = post ?? throw new ArgumentNullException(nameof(post)); + _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); + _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); + } + + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } + + [RelayCommand] + public async Task LoadAsync() + { + IsBusy = true; + try + { + // Load circles and ACL entries in parallel — both are + // independent reads on the same host. The caller's uid + // is implicit in both endpoints. + var circlesTask = _circleClient.GetMyCirclesAsync(); + var aclTask = _aclClient.GetMyAclAsync(); + await Task.WhenAll(circlesTask, aclTask); + + var circles = circlesTask.Result ?? new List(); + MyCircles = new ObservableCollection(circles); + + var allAcl = aclTask.Result ?? new List(); + AclEntries = new ObservableCollection( + allAcl.Where(a => a.BlogPostId == Post.Id)); + + StatusMessage = $"{AclEntries.Count} autorisation(s)"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task AddAsync() + { + if (SelectedCircleToAdd is null) + { + StatusMessage = "Sélectionnez un cercle à ajouter"; + return; + } + + IsBusy = true; + try + { + var created = await _aclClient.GrantAsync(new CircleAuthorizationDto + { + CircleId = SelectedCircleToAdd.Id, + BlogPostId = Post.Id, + Comment = false, + }); + if (created is not null) + { + AclEntries.Add(created); + StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; + } + else + { + StatusMessage = "Autorisation refusée par le serveur"; + } + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public async Task RevokeAsync(CircleAuthorizationDto? acl) + { + if (acl is null) return; + IsBusy = true; + try + { + await _aclClient.RevokeAsync(acl.CircleId); + AclEntries.Remove(acl); + StatusMessage = "Autorisation révoquée"; + } + catch (Exception ex) + { + StatusMessage = $"Erreur: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/PostIt/PostIt/Views/CirclesPage.axaml b/src/PostIt/PostIt/Views/CirclesPage.axaml new file mode 100644 index 000000000..d9320eb20 --- /dev/null +++ b/src/PostIt/PostIt/Views/CirclesPage.axaml @@ -0,0 +1,66 @@ + + + + + +