Compare commits

...

32 commits

Author SHA1 Message Date
4370019785 Merge pull request 'dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr'' (#18) from fix/github-action-apk into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 15s
Dotnet build and test / build (push) Failing after 3m49s
Reviewed-on: #18
2026-08-16 15:51:08 +01:00
eaa4c16936
dockerfile: drop inline 'dotnet nuget add source isn.pschneider.fr'
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 16s
Dotnet build and test / build (pull_request) Failing after 7m32s
The project-level NuGet.config (added in 94012c51) 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 . .').
2026-08-16 14:10:10 +01:00
4862260608 Merge pull request 'forgejo/ci: run build in pazof/yavsc-build-env container' (#17) from feat/postit-release-page into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 9s
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #17
2026-08-16 13:50:12 +01:00
cc50a8bbc8
ci: unshallow clone for GitVersion
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Successful in 5m41s
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.
2026-08-16 13:39:53 +01:00
94012c51ab
nuget: add NuGet.config pointing at isn.pschneider.fr
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 14s
Dotnet build and test / build (pull_request) Failing after 1m58s
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.
2026-08-16 13:35:44 +01:00
5f50135c7f
ci: drop working-directory, cd into /src/_src explicitly
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 2m1s
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.
2026-08-16 13:27:36 +01:00
86e59ad1c1
submodule: switch URL from SSH to HTTPS for forgejo anonymous access
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 13s
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/.
2026-08-16 13:26:12 +01:00
7370f48aac
ci: clone via GITHUB_REF instead of GITHUB_REF_NAME
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 12s
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/<branch>
in push context) and fetch + checkout FETCH_HEAD. workflow_dispatch
falls back to the default branch.
2026-08-16 13:22:56 +01:00
380b5d12c8
ci: replace actions/checkout with manual git clone (image has no node)
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 9s
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.
2026-08-16 13:15:10 +01:00
4ac8e14ba9
run on docker
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 9s
Dotnet build and test / build (pull_request) Failing after 2m25s
2026-08-16 13:03:17 +01:00
e165e7bb61
dotnet-android-build-image: pin to <sha-ou-tag>
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 15s
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-16 12:49:50 +01:00
0fe293bcd2
forgejo/ci: run build in pazof/yavsc-build-env container
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 17s
Dotnet build and test / build (pull_request) Has been cancelled
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.
2026-08-15 22:18:03 +01:00
a906a96c94 Merge pull request 'postit: validate CHANGELOG section on tag, classify stable/preview/unstable' (#16) from feat/postit-release-page into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 20s
Dotnet build and test / build (push) Failing after 1m23s
Reviewed-on: #16
2026-08-15 22:02:18 +01:00
93f39ca872
forgejo/ci: pin dotnet sdk 10.0.x for buildAndTest
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Failing after 30s
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.
2026-08-15 22:00:36 +01:00
2a8c854a4c Merge branch 'main' into feat/postit-release-page
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Failing after 32s
2026-08-15 20:35:39 +01:00
99a62ebf81
postit: validate CHANGELOG section on tag, classify stable/preview/unstable
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 8s
Dotnet build and test / build (pull_request) Has been cancelled
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 ## [<tag>] - <canal>
- 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).
2026-08-15 20:34:47 +01:00
c24935c94b Merge pull request 'feat/postit-release-page' (#15) from feat/postit-release-page into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Successful in 9s
Dotnet build and test / build (push) Failing after 49s
Reviewed-on: #15
2026-08-15 18:13:05 +01:00
825d54439c
postit: validate CHANGELOG section on tag, classify stable/preview/unstable
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
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 ## [<tag>] - <canal>
- 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).
2026-08-15 15:51:42 +01:00
e3987d7890
postit: add CHANGELOG.md with Keep a Changelog format
Initialise le changelog du projet au format Keep a Changelog 1.1.0,
en français, avec une section [Unreleased] vide prête à être curée
au moment de la première release.

Le préambule documente la convention de parité du patch :
- pair → stable
- impair → preview
- suffixe → instable

Cette convention est partagée avec le dépôt postit-debian pour la
production des paquets .deb (alignement à traiter dans une PR séparée).
2026-08-15 15:44:47 +01:00
eebc83cf7e
Add comments API support and tests
Some checks failed
Dotnet build and test / build (push) Has been cancelled
Dotnet build and test / log-the-inputs (push) Has been cancelled
2026-08-10 22:27:52 +01:00
0fd9e40d67
Fix blog comment endpoint path and add regression test
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-10 21:48:03 +01:00
0d3fbf22c3
GetUserId_reads_NameIdentifier_when_sub_was_mapped
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-10 18:34:01 +01:00
44b391d496
Activity protection
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-10 18:12:59 +01:00
cd03b04755
re-refacto BlogPost serialization
Some checks failed
Dotnet build and test / build (push) Has been cancelled
Dotnet build and test / log-the-inputs (push) Has been cancelled
2026-08-05 21:11:22 +01:00
64547840e4
refacto BlogPost serialization
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-05 21:05:14 +01:00
ff7a6ac16d Merge pull request 'publish android' (#13) from build into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #13
2026-08-03 02:36:11 +01:00
c8b05a8950
publish android
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-03 02:35:50 +01:00
42ad1b623d Merge pull request 'build' (#12) from build into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #12
2026-08-03 02:17:32 +01:00
7d1cca9df0
build
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-03 02:16:57 +01:00
3744d9ae9c
Enable blogs on connected status
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-03 01:48:15 +01:00
b25e0e842e
refacto blogPost
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-08-03 01:36:12 +01:00
1d716380dd Merge pull request 'ci: publish APK to GitHub release on v* tag' (#11) from release into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #11
2026-08-02 23:29:21 +01:00
45 changed files with 911 additions and 127 deletions

View file

@ -37,17 +37,23 @@ jobs:
build:
runs-on: debian-latest
runs-on: docker
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: 9.0.x
- name: Clone yavsc
run: |
cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
cd _src
if [ -n "${GITHUB_REF:-}" ]; then
git fetch origin "$GITHUB_REF"
git checkout FETCH_HEAD
fi
git submodule update --init --recursive
echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Restore dependencies
run: dotnet restore
run: cd /src/_src && dotnet restore
- name: Build
run: dotnet build --no-restore
run: cd /src/_src && dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
run: cd /src/_src && dotnet test --no-build --verbosity normal

View file

@ -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
# Job de validation : parse le tag, vérifie le format, applique la règle
# de parité du patch (pair=stable / impair=preview / suffixe=instable),
# et s'assure que CHANGELOG.md contient une section cohérente.
# Sans ce job, le job publish-release peut être bypassé (un attaquant
# qui contrôle un tag ne peut pas publier de release sans une section
# changelog cohérente).
validate-release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout du code
uses: actions/checkout@v7
- 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 <<EOF pour le body multi-lignes (pattern GitHub Actions).
{
echo "RELEASE_BODY<<EOF"
echo "$BODY"
echo "EOF"
echo "RELEASE_CHANNEL=$CHANNEL"
if [[ "$CHANNEL" == "stable" ]]; then
echo "IS_PRERELEASE=false"
else
echo "IS_PRERELEASE=true"
fi
} >> "$GITHUB_ENV"
publish-release:
# Uniquement déclenché par un tag v*. Le job apk-deploy tourne en
# parallèle, on partage l'artefact entre jobs.
if: startsWith(github.ref, 'refs/tags/v')
needs: apk-deploy
# 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/<owner>/<repo>/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 }}

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "external/dotnet-android-build-image"]
path = external/dotnet-android-build-image
url = https://forgejo.pschneider.fr/notazof/dotnet-android-build-image.git

29
CHANGELOG.md Normal file
View file

@ -0,0 +1,29 @@
# Changelog
Toutes les modifications notables de PostIt et de la plateforme Yavsc
sont documentées dans ce fichier.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
À noter : la **parité du numéro de patch** porte une signification de canal :
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`.
## [Unreleased]
### Added
### Changed
### Fixed
### Removed
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD

View file

@ -11,5 +11,6 @@
from without conflicting names.
-->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
</PropertyGroup>
</Project>

View file

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

View file

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

23
NuGet.config Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Project-level NuGet configuration.
The yavsc solution depends on HigginsSoft.IdentityServer8.* 8.1.0-alpha.*,
published only on the internal feed https://isn.pschneider.fr. The public
nuget.org feed has 8.0.4 as the nearest version, which causes NU1102 on
restore for every project that depends on it (Yavsc.Org, Yavsc.Api,
Yavsc.Blogs, Yavsc.Server, cli, tests).
Listing 'isn' before 'nuget.org' here ensures that restore finds the
alpha packages first, then falls back to nuget.org for everything else.
Both feeds are reachable anonymously; no credentials are stored here.
See AGENTS.md for the rationale.
-->
<configuration>
<packageSources>
<clear />
<add key="isn" value="https://isn.pschneider.fr/api/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

@ -0,0 +1 @@
Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d

View file

@ -1,6 +1,7 @@
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Models;
namespace PostIt.Tests;

View file

@ -24,7 +24,7 @@ public partial class App : Application
/// binding sink with a cross-thread exception inside
/// <c>DataValidationErrors.SetErrors</c>.
/// </summary>
public IServiceProvider? Services { get; private set; }
public IServiceProvider? ServiceProvider { get; private set; }
private MainWindow window;
public App()
{
@ -91,19 +91,17 @@ public partial class App : Application
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
var provider = services.BuildServiceProvider();
ServiceProvider = services.BuildServiceProvider();
// Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates) still gets
// the same instance the rest of the app is using. Idempotent:
// re-binding from a second App boot (tests) is a no-op.
Settings.BindToServiceProvider(provider);
Services = provider;
Settings.BindToServiceProvider(ServiceProvider);
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider));
DataTemplates.Add(new ViewLocator(ServiceProvider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
@ -113,7 +111,7 @@ public partial class App : Application
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
provider.GetRequiredService<SettingsPage>().DataContext = settings;
ServiceProvider.GetRequiredService<SettingsPage>().DataContext = settings;
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
@ -134,8 +132,8 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var homePage = provider.GetRequiredService<HomePage>();
homePage.DataContext = provider.GetRequiredService<HomePageViewModel>();
var homePage = ServiceProvider.GetRequiredService<HomePage>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
@ -155,8 +153,8 @@ public partial class App : Application
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot;
var hp = provider.GetRequiredService<HomePage>();
hp.DataContext = provider.GetRequiredService<HomePageViewModel>();
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync();
};
@ -187,7 +185,7 @@ public partial class App : Application
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = provider.GetRequiredService<SettingsPage>();
var settingsPage = ServiceProvider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
@ -196,13 +194,13 @@ public partial class App : Application
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(provider, api);
window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
singleView.MainView = new MainWindow
{
DataContext = provider.GetRequiredService<HomePageViewModel>()
DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>()
};
}
}
@ -243,8 +241,8 @@ public partial class App : Application
public static async Task PushMainPageAsync()
{
var app = (App)Current;
var mainVm = app.Services.GetRequiredService<MainPageViewModel>();
var mainPage = app.Services.GetRequiredService<MainPage>();
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
}

View file

@ -1,16 +1,37 @@
using System;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Blogspot;
namespace PostIt.Models;
public class BlogPost
public class BlogPost : IBlogPost
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Article { get; set; }
public string? Photo { get; set; }
public string? AuthorId { get; set; }
public DateTime DateCreated { get; set; }
public string? UserCreated { get; set; }
public DateTime DateModified { get; set; }
public string? UserModified { get; set; }
public string AuthorId { get; set; }
public IApplicationUser Author { get; set; }
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 bool AuthorizeCircle(long circleId)
{
throw new NotImplementedException();
}
public ICircleAuthorization[] GetACL()
{
throw new NotImplementedException();
}
public string[] GetTags()
{
throw new NotImplementedException();
}
}

View file

@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -7,6 +8,7 @@ public class HomePageViewModel : ViewModelBase
{
public YavscApiClient Api { get; }
public Settings Settings { get; }
public SessionStatusViewModel SessionStatus { get; }
private string _welcomeText = "Welcome to PostIt!";
public string WelcomeText
@ -18,10 +20,12 @@ public class HomePageViewModel : ViewModelBase
public override bool CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => false; protected set => throw new System.NotImplementedException(); }
public HomePageViewModel(YavscApiClient api, Settings settings)
public HomePageViewModel(YavscApiClient api, Settings settings, SessionStatusViewModel sessionStatus)
{
Api = api;
Settings = settings;
SessionStatus = sessionStatus;
}
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
/// <summary>
@ -33,5 +37,8 @@ public class HomePageViewModel : ViewModelBase
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
/// designer-only duplicate instance is therefore harmless.
/// </summary>
public HomePageViewModel() : this(null!, new Settings()) { }
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
{
}
}

View file

@ -16,6 +16,7 @@
HorizontalAlignment="Center"/>
<Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"/>
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ContentPage>

View file

@ -28,7 +28,7 @@ public partial class MainPage : ContentPage
// Resolve via the App's DI container so the page gets
// the canonical services (Api client, settings, ...).
var app = Application.Current as App;
var services = app?.Services;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<SignaturePage>();

View file

@ -1,19 +0,0 @@

using Yavsc.Abstract.Identity;
namespace Yavsc
{
public interface IBlogPostPayLoad
{
string Article { get; set; }
string Photo { get; set; }
}
public interface IBlogPost : IBlogPostPayLoad, ITrackedEntity, IIdentified<long>, ITitle
{
string AuthorId { get; set; }
IApplicationUser Author { get; }
}
}

View file

@ -0,0 +1,14 @@
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Interfaces;
namespace Yavsc.Blogspot
{
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
{
IApplicationUser Author { get; }
}
}

View file

@ -0,0 +1,9 @@
namespace Yavsc.Blogspot
{
public interface IBlogPostPayLoad
{
string Article { get; set; }
string Photo { get; set; }
}
}

View file

@ -1,10 +1,14 @@
using Yavsc.Interfaces;
namespace Yavsc.Abstract.Identity.Security
{
public interface ICircleAuthorized
public interface ICircleAuthorized : ITaggable<long>
{
long Id { get; set; }
string AuthorId { get; }
bool AuthorizeCircle(long circleId);
ICircleAuthorization [] GetACL();
}

View file

@ -1,9 +1,7 @@
namespace Yavsc.Interfaces
{
public interface ITaggable<K>
public interface ITaggable<K> : IIdentified<K>
{
string [] GetTags();
K Id { get; }
}
}
}

View file

@ -15,7 +15,6 @@ namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/activity")]
[AllowAnonymous]
public class ActivityApiController : Controller
{
private ApplicationDbContext _context;
@ -88,7 +87,7 @@ namespace Yavsc.Controllers
}
// POST: api/ActivityApi
[HttpPost,Authorize("AdministratorOnly")]
[HttpPost, Authorize("AdministratorOnly")]
public async Task<IActionResult> PostActivity([FromBody] Activity activity)
{
if (!ModelState.IsValid)

View file

@ -1,15 +1,15 @@

using System;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Identity;
using Yavsc.Server.Helpers;
#nullable enable
[Authorize, Route("~/api/gcm")]
public class NativeConfidentialController : Controller
{

View file

@ -0,0 +1,156 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
[Collection("JwtClaimMapping")]
public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWebServerFixture>
{
private readonly MappedClaimsBlogsWebServerFixture _fixture;
public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
private HttpClient NewClient(string subject = "tester")
{
var http = new HttpClient
{
BaseAddress = new Uri(_fixture.Addresses.First())
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IssueMappedClaimsToken(subject));
return http;
}
private static string IssueMappedClaimsToken(string subject)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new("sub", subject),
new("scope", "blogs"),
};
var token = new JwtSecurityToken(
issuer: TestTokenIssuer.Issuer,
audience: TestTokenIssuer.Audience,
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: new SigningCredentials(
TestTokenIssuer.SigningKey,
SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
[Fact]
public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user()
{
ResetDatabase();
using var http = NewClient(subject: "mapped-user");
var draft = new BlogPost
{
Id = 0,
Title = "Billet JWT remappe",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
Assert.Equal("mapped-user", created!.AuthorId);
}
[Fact]
public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update()
{
ResetDatabase();
using var http = NewClient(subject: "mapped-owner");
var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost
{
Id = 0,
Title = "Billet à modifier",
AuthorId = "payload-attacker",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
});
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
{
Id = created.Id,
Title = "Billet modifié",
AuthorId = created.AuthorId,
Article = "Contenu mis à jour.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
});
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
}
[Fact]
public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner()
{
ResetDatabase();
using var ownerHttp = NewClient(subject: "mapped-owner");
var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost
{
Id = 0,
Title = "Billet protégé",
AuthorId = "payload-attacker",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
});
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
using var otherHttp = NewClient(subject: "mapped-other");
var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
{
Id = created.Id,
Title = "Tentative de modification",
AuthorId = created.AuthorId,
Article = "Contenu non autorisé.",
DateCreated = created.DateCreated,
DateModified = DateTime.UtcNow
});
Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode);
}
}

View file

@ -1,10 +1,12 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
@ -20,6 +22,7 @@ namespace Yavsc.Blogs.Tests;
/// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework.
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
@ -148,6 +151,85 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
}
[Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{
ResetDatabase();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Billet avec auteur",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var created = await postResponse.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(created);
Assert.Equal("tester", created!.AuthorId);
var listResponse = await http.GetAsync("/api/v1/blog");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
}
[Fact]
public async Task PostBlogComment_returns_201_for_existing_post()
{
ResetDatabase();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = "Billet commentable",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft);
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
var createdPost = await postResponse.Content.ReadFromJsonAsync<BlogPost>();
Assert.NotNull(createdPost);
var commentResponse = await http.PostAsJsonAsync("/api/v1/blogcomments", new
{
Article = "Premier commentaire",
ReceiverId = createdPost!.Id
});
Assert.Equal(HttpStatusCode.Created, commentResponse.StatusCode);
using var doc = JsonDocument.Parse(await commentResponse.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.TryGetProperty("id", out var id));
Assert.True(id.GetInt64() > 0);
Assert.True(doc.RootElement.TryGetProperty("dateCreated", out _));
}
[Fact]
public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
{
var principal = new ClaimsPrincipal(
new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "tester")],
authenticationType: "Bearer"));
Assert.Equal("tester", principal.GetUserId());
}
[Fact]
public async Task GetBlog_returns_401_when_no_token_is_provided()
{
@ -239,7 +321,8 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// The list should now be empty.
var listResponse = await http.GetAsync("/api/v1/blog");
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
String response = await listResponse.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(response);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}

View file

@ -0,0 +1,8 @@
using Xunit;
namespace Yavsc.Blogs.Tests;
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
public sealed class JwtClaimMappingCollection
{
}

View file

@ -0,0 +1,109 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers;
using Yavsc.Models;
using Yavsc.Services;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Dedicated integration-test host that mirrors the production JWT
/// remapping behavior: MapInboundClaims remains enabled and the
/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier.
/// This is the closest in-process reproduction of the production
/// authentication surface for the blog API.
/// </summary>
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
{
private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
private readonly Dictionary<string, string> _savedInboundMap;
private readonly WebApplication _app;
public MappedClaimsBlogsWebServerFixture()
{
_savedInboundMap = new Dictionary<string, string>(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:5104");
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
builder.Services.AddSingleton<IFileSystemAuthManager>(new NoopFileSystemAuthManager());
builder.Services.AddScoped<BlogSpotService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
builder.Services.AddControllers()
.AddApplicationPart(typeof(BlogApiController).Assembly);
builder.Services.AddAuthorization(opt =>
{
opt.AddPolicy("BlogScope", policy =>
{
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "blogs");
});
});
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
options.MapInboundClaims = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
RoleClaimType = YavscConstants.RoleClaimType,
NameClaimType = YavscConstants.NameClaimType,
};
});
_app = builder.Build();
_app.UseRouting();
_app.UseAuthentication();
_app.UseAuthorization();
_app.MapControllers();
_app.StartAsync().GetAwaiter().GetResult();
Addresses = ["http://127.0.0.1:5104"];
Services = _app.Services;
}
public IReadOnlyList<string> Addresses { get; }
public IServiceProvider Services { get; }
public void Dispose()
{
_app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().AsTask().GetAwaiter().GetResult();
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
foreach (var kvp in _savedInboundMap)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value;
}
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
{
}
}
}

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Blogspot;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;

View file

@ -0,0 +1,92 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
namespace Yavsc.Org.Tests.Controllers;
public class CommentsApiIntegrationTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public CommentsApiIntegrationTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Post_blogcomments_json_returns_201_and_persists_comment()
{
long postId;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
if (!db.Users.Any(u => u.Id == TestUserMiddleware.UserId))
{
db.Users.Add(new ApplicationUser
{
Id = TestUserMiddleware.UserId,
UserName = "test-user",
NormalizedUserName = "TEST-USER",
Email = "test-user@example.com",
NormalizedEmail = "TEST-USER@EXAMPLE.COM",
EmailConfirmed = true,
SecurityStamp = Guid.NewGuid().ToString("N"),
ConcurrencyStamp = Guid.NewGuid().ToString("N")
});
}
var post = new BlogPost
{
Title = "Post for comment API test",
AuthorId = TestUserMiddleware.UserId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
db.BlogSpot.Add(post);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
postId = post.Id;
}
var http = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = true,
AllowAutoRedirect = false
});
http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
var response = await http.PostAsJsonAsync(
"/api/v1/blogcomments",
new
{
Article = "Comment API integration test",
ReceiverId = postId
},
TestContext.Current.CancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(
response.StatusCode != HttpStatusCode.InternalServerError,
$"Unexpected 500 on POST /api/v1/blogcomments. Body: {responseBody}");
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Contains("\"id\"", responseBody, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\"dateCreated\"", responseBody, StringComparison.OrdinalIgnoreCase);
using var verifyScope = _factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var stored = await verifyDb.Comment
.OrderByDescending(c => c.Id)
.FirstOrDefaultAsync(c => c.ReceiverId == postId, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("Comment API integration test", stored!.Article);
Assert.Equal(TestUserMiddleware.UserId, stored.AuthorId);
}
}

View file

@ -0,0 +1,63 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Controllers;
using Yavsc.Models;
using Yavsc.Models.Blog;
namespace Yavsc.Org.Tests.Controllers;
public class CommentsControllerTests
{
[Fact]
public async Task Create_sets_author_and_persists_comment()
{
var dbName = $"comments-controller-{Guid.NewGuid():N}";
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
await using var db = new ApplicationDbContext(options);
var post = new BlogPost
{
Title = "Post de test",
AuthorId = "post-author",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
db.BlogSpot.Add(post);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
var controller = new CommentsController(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "comment-author")
], "TestAuth"))
}
}
};
var comment = new Comment
{
ReceiverId = post.Id,
Article = "Commentaire de test",
Visible = true
};
var result = await controller.Create(comment);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var stored = await db.Comment.SingleAsync(TestContext.Current.CancellationToken);
Assert.Equal("comment-author", stored.AuthorId);
Assert.Equal(post.Id, stored.ReceiverId);
Assert.Equal("Commentaire de test", stored.Article);
}
}

View file

@ -33,15 +33,11 @@ public class TestUserStartupFilter : IStartupFilter
{
return app =>
{
// Replay the production pipeline first (this is what
// Program.Main + ConfigurePipeline set up, including
// UseAuthentication and UseAuthorization).
next(app);
// Then add our middleware on top. UseMiddleware<T> wires
// it through the same IMiddlewareActivator the framework
// uses, so the dependency on TestUserMiddleware is
// resolved from the request scope.
app.UseMiddleware<TestUserMiddleware>();
// Replay the production pipeline after the test middleware,
// so downstream auth and controllers can see the injected
// principal when no real login flow is used.
next(app);
};
}
}

View file

@ -1,6 +1,7 @@
{
"Site": {
"Authority": "https://localhost:5101",
"Audience": ["blogs"],
"Title": "Yavsc dev",
"Slogan": "Yavsc : WIP.",
"Banner": "/images/yavsc.png",

View file

@ -7,6 +7,7 @@ using Yavsc.Models.Blog;
using Microsoft.Extensions.Options;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
using Yavsc.Blogspot;
// For more information on enabling Web API for empty projects, visit http://go.microsoft.com/fwlink/?LinkID=397860
@ -70,7 +71,7 @@ namespace Yavsc.Org.Controllers
try
{
var blog = await blogSpotService.Details(User, id.Value);
ViewBag.apicmtctlr = "/api/blogcomments";
ViewBag.apicmtctlr = "/api/v1/blogcomments";
ViewBag.moderatoFlag = User.IsInMsRole(YavscConstants.BlogModeratorGroupName);
return View(blog);

View file

@ -2,16 +2,17 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
/// <summary>
/// Comment some post.
/// </summary>
[Route("~/api/v1/blogcomments")]
public class CommentsController : Controller
{
private readonly ApplicationDbContext _context;
@ -21,7 +22,68 @@ namespace Yavsc.Controllers
_context = context;
}
[HttpGet("{id:long}", Name = "GetComment")]
public async Task<IActionResult> GetComment(long id)
{
var comment = await _context.Comment.SingleOrDefaultAsync(m => m.Id == id);
if (comment == null)
{
return NotFound();
}
return Ok(comment);
}
[HttpPost]
[IgnoreAntiforgeryToken]
[Consumes("application/json")]
public async Task<IActionResult> Post([FromBody] CommentPost post)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.GetUserId();
if (string.IsNullOrEmpty(uid))
{
return Challenge();
}
var article = await _context.BlogSpot.FirstOrDefaultAsync(p => p.Id == post.ReceiverId);
if (article == null)
{
ModelState.AddModelError(nameof(post.ReceiverId), "not found");
return BadRequest(ModelState);
}
if (post.ParentId != null)
{
var parentExists = await _context.Comment.AnyAsync(c => c.Id == post.ParentId);
if (!parentExists)
{
ModelState.AddModelError(nameof(post.ParentId), "not found");
return BadRequest(ModelState);
}
}
var comment = new Comment
{
ReceiverId = post.ReceiverId,
Article = post.Article,
ParentId = post.ParentId,
AuthorId = uid,
UserModified = uid
};
_context.Comment.Add(comment);
await _context.SaveChangesAsync(uid);
return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated });
}
// GET: Comments
[HttpGet]
public async Task<IActionResult> Index()
{
var applicationDbContext = _context.Comment.Include(c => c.Post);
@ -45,19 +107,24 @@ namespace Yavsc.Controllers
return View(comment);
}
// GET: Comments/Create
// GET: Comments/Create (MVC form endpoint)
[HttpGet("form")]
public IActionResult Create()
{
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Post");
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
return View();
}
// POST: Comments/Create
[HttpPost]
// POST: Comments/Create (MVC form endpoint)
[HttpPost("form")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Comment comment)
{
comment.UserCreated = User.GetUserId();
// AuthorId/UserCreated is set server-side after model binding;
// remove the stale binding error so a valid authenticated POST
// does not fall into the invalid branch.
ModelState.Remove(nameof(Comment.AuthorId));
if (ModelState.IsValid)
{
@ -65,7 +132,7 @@ namespace Yavsc.Controllers
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Post", comment.ReceiverId);
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title", comment.ReceiverId);
return View(comment);
}
@ -82,7 +149,7 @@ namespace Yavsc.Controllers
{
return NotFound();
}
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Post", comment.ReceiverId);
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title", comment.ReceiverId);
return View(comment);
}
@ -97,7 +164,7 @@ namespace Yavsc.Controllers
await _context.SaveChangesAsync();
return RedirectToAction("Index");
}
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Post", comment.ReceiverId);
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title", comment.ReceiverId);
return View(comment);
}

View file

@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/dimiss")]
[Route("api/v1/dimiss")]
public class DimissClicksApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

@ -1189,6 +1189,8 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
}
}
#nullable enable
static void LoadGoogleConfig(IConfigurationRoot configuration)
{
string? googleClientFile = configuration["Authentication:Google:GoogleWebClientJson"];
@ -1204,6 +1206,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
Config.GServiceAccount = JsonConvert.DeserializeObject<GoogleServiceAccount>(safile.OpenText().ReadToEnd());
}
}
#nullable disable
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
bool enableDirectoryBrowsing = false)

View file

@ -3,6 +3,7 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Yavsc;
using Yavsc.Blogspot;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;

View file

@ -23,7 +23,7 @@ namespace Yavsc.ViewComponents
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id);
if (comment == null)
throw new InvalidOperationException();
ViewBag.apictlr = "/api/blogcomments";
ViewBag.apictlr = "/api/v1/blogcomments";
return View("Default", comment);
}

View file

@ -7,7 +7,7 @@
<script src="~/js/comment.js" asp-append-version="true"></script>
<script>
$.psc.blogcomment.prototype.options.lang = '@System.Globalization.CultureInfo.CurrentUICulture.Name';
$.psc.blogcomment.prototype.options.apictrlr = '/api/blogcomments';
$.psc.blogcomment.prototype.options.apictrlr = '/api/v1/blogcomments';
$.psc.blogcomment.prototype.options.authorId = '@User.GetUserId()';
$.psc.blogcomment.prototype.options.authorName = '@User.GetUserName()';
$(document).ready(function() {
@ -23,7 +23,7 @@
ReceiverId: @Model.Id
}),
error: function(xhr,data) {
if (xhr.status=400)
if (xhr.status === 400)
{
if (xhr.responseJSON)
{
@ -45,7 +45,7 @@ $('#commentValidation').html(
var nnode = '<div data-type="blogcomment" data-id="'+data.id+'" data-allow-edit="True" data-allow-moderate="@ViewBag.moderatoFlag" data-date="'+data.dateCreated+'" data-username="@User.GetUserName()">'+comment+'</div>';
$('#comments').append($(nnode).blogcomment())
},
url:'/api/blogcomments'
url:'/api/v1/blogcomments'
});
});
})

View file

@ -1,3 +1,4 @@
@model IEnumerable<IBlogPost>
@{
ViewBag.Title = "Blogs, l'index";
@ -43,13 +44,13 @@
<a asp-action="Create">Create a new article</a>
</p>
}
<div class="blog-index">
@{
int maxTextLen = 75;
foreach (var post in Model) {
<div class="post card">
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink" >
<div class="float-left"><img class="photo card-photo" src="@post.Photo" ></div>
@ -63,24 +64,24 @@
posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")
@if ((post.DateModified - post.DateCreated).Minutes > 0){ 
@:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")
})
}
</span>
</div>
<div class="actiongroup">
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
{
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
<a asp-action="Details" asp-route-id="@((IBlogPost)post).Id" class="btn btn-light">Details</a>
}
else
else
{
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
}
@if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
@if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
{
<a asp-action="Edit" asp-route-id="@post.Id" class="btn btn-primary">Edit</a>
<a asp-action="Delete" asp-route-id="@post.Id" class="btn btn-danger">Delete</a>
}
</div>
</div>

View file

@ -1,5 +1,6 @@
@using Microsoft.AspNetCore.Mvc.Localization
@using Yavsc
@using Yavsc.Blogspot
@using Yavsc.Models
@using Yavsc.Models.Musical;
@using Yavsc.Models.Drawing;

View file

@ -3,7 +3,7 @@ var notifClick =
function(nid) {
if (nid > 0) {
$.get({
url: '/api/dimiss/click/' + nid,
url: '/api/v1/dimiss/click/' + nid,
success: $('div[data-nid='+nid+']').remove()
});
}

View file

@ -32,7 +32,9 @@ namespace Yavsc.Server.Helpers
public static string GetUserId(this ClaimsPrincipal user)
{
return user.FindFirstValue("sub");
return user.FindFirstValue("sub")
?? user.FindFirstValue(ClaimTypes.NameIdentifier)
?? user.FindFirstValue("nameid");
}
public static string GetUserName(this ClaimsPrincipal user)

View file

@ -3,15 +3,14 @@ using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Interfaces;
using Yavsc.Models.Access;
using Yavsc.Models.Relationship;
using Yavsc.Blogspot;
namespace Yavsc.Models.Blog
{
public class BlogPost :
IBlogPost, ICircleAuthorized, ITaggable<long>
public class BlogPost : IBlogPost
{
[Key(), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Display(Name = "Identifiant du post")]
@ -36,7 +35,7 @@ namespace Yavsc.Models.Blog
public string? AuthorId { get; set; }
[Display(Name = "Auteur")]
public virtual ApplicationUser? Author { set; get; }
public virtual ApplicationUser Author { set; get; }
[Display(Name = "Date de création")]
@ -96,6 +95,6 @@ namespace Yavsc.Models.Blog
[InverseProperty("Post")]
public virtual List<Comment> Comments { get; set; }
IApplicationUser IBlogPost.Author { get => this.Author; }
IApplicationUser IBlogPost.Author => Author;
}
}

View file

@ -10,6 +10,7 @@ using Yavsc.Server.Helpers;
using Yavsc.Services;
using Yavsc.ViewModels.Auth;
using Microsoft.AspNetCore.Http;
using Yavsc.Blogspot;
public class BlogSpotService
{

View file

@ -197,7 +197,7 @@ namespace Yavsc.Services
if (credential.IsCreateScopedRequired)
{
credential = credential.CreateScoped(scopesCalendar);
}/*
}/*
var credential = await GoogleHelpers.GetCredentialForApi(new string [] { scopeCalendar });
if (credential.IsCreateScopedRequired)
{