Compare commits
32 commits
23262bcc17
...
4370019785
| Author | SHA1 | Date | |
|---|---|---|---|
| 4370019785 | |||
|
eaa4c16936 |
|||
| 4862260608 | |||
|
cc50a8bbc8 |
|||
|
94012c51ab |
|||
|
5f50135c7f |
|||
|
86e59ad1c1 |
|||
|
7370f48aac |
|||
|
380b5d12c8 |
|||
|
4ac8e14ba9 |
|||
|
e165e7bb61 |
|||
|
0fe293bcd2 |
|||
| a906a96c94 | |||
|
93f39ca872 |
|||
| 2a8c854a4c | |||
|
99a62ebf81 |
|||
| c24935c94b | |||
|
825d54439c |
|||
|
e3987d7890 |
|||
|
eebc83cf7e |
|||
|
0fd9e40d67 |
|||
|
0d3fbf22c3 |
|||
|
44b391d496 |
|||
|
cd03b04755 |
|||
|
64547840e4 |
|||
| ff7a6ac16d | |||
|
c8b05a8950 |
|||
| 42ad1b623d | |||
|
7d1cca9df0 |
|||
|
3744d9ae9c |
|||
|
b25e0e842e |
|||
| 1d716380dd |
45 changed files with 911 additions and 127 deletions
|
|
@ -37,17 +37,23 @@ jobs:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|
||||||
runs-on: debian-latest
|
runs-on: docker
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- name: Clone yavsc
|
||||||
- name: Setup .NET
|
run: |
|
||||||
uses: actions/setup-dotnet@v5
|
cd /src
|
||||||
with:
|
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||||
dotnet-version: 9.0.x
|
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
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: cd /src/_src && dotnet restore
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build --no-restore
|
run: cd /src/_src && dotnet build --no-restore
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test --no-build --verbosity normal
|
run: cd /src/_src && dotnet test --no-build --verbosity normal
|
||||||
|
|
|
||||||
128
.github/workflows/docker-publish-android.yml
vendored
128
.github/workflows/docker-publish-android.yml
vendored
|
|
@ -5,8 +5,14 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- '*'
|
||||||
workflow_dispatch:
|
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
|
# softprops/action-gh-release a besoin de contents: write
|
||||||
# pour publier une release + uploader un asset.
|
# pour publier une release + uploader un asset.
|
||||||
|
|
@ -41,11 +47,113 @@ jobs:
|
||||||
path: ./PostIt.Android.apk
|
path: ./PostIt.Android.apk
|
||||||
retention-days: 7
|
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:
|
publish-release:
|
||||||
# Uniquement déclenché par un tag v*. Le job apk-deploy tourne en
|
# Déclenché uniquement par un push de tag. Le job apk-deploy produit
|
||||||
# parallèle, on partage l'artefact entre jobs.
|
# l'artefact ; validate-release garantit la cohérence du tag et du
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
# changelog avant publication.
|
||||||
needs: apk-deploy
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: [apk-deploy, validate-release]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Récupérer l'APK depuis l'artefact
|
- name: Récupérer l'APK depuis l'artefact
|
||||||
|
|
@ -61,7 +169,9 @@ jobs:
|
||||||
# apparaîtra dans l'asset et donc dans le permalink :
|
# apparaîtra dans l'asset et donc dans le permalink :
|
||||||
# https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
|
# https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
|
||||||
files: ./PostIt.Android.apk
|
files: ./PostIt.Android.apk
|
||||||
# generate_release_notes: true -> évite d'avoir à maintenir
|
# Le body est extrait de la section CHANGELOG.md correspondant
|
||||||
# le corps de release à la main. Décommente si tu veux.
|
# au tag, exposée par validate-release via $GITHUB_ENV.
|
||||||
# generate_release_notes: true
|
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
3
.gitmodules
vendored
Normal 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
29
CHANGELOG.md
Normal 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
|
||||||
|
|
@ -11,5 +11,6 @@
|
||||||
from without conflicting names.
|
from without conflicting names.
|
||||||
-->
|
-->
|
||||||
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
|
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
|
||||||
|
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
|
||||||
# (2) Tout le code source
|
# (2) Tout le code source
|
||||||
COPY . .
|
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
|
# (4) Restore
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,6 @@ COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
|
||||||
# 4. Copie de l'intégralité du code source
|
# 4. Copie de l'intégralité du code source
|
||||||
COPY . .
|
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
|
# 4. Restauration des dépendances pour tous les projets
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|
||||||
|
|
|
||||||
23
NuGet.config
Normal file
23
NuGet.config
Normal 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>
|
||||||
1
external/dotnet-android-build-image
vendored
Submodule
1
external/dotnet-android-build-image
vendored
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using PostIt.Models;
|
using PostIt.Models;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
using Yavsc.Models;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ public partial class App : Application
|
||||||
/// binding sink with a cross-thread exception inside
|
/// binding sink with a cross-thread exception inside
|
||||||
/// <c>DataValidationErrors.SetErrors</c>.
|
/// <c>DataValidationErrors.SetErrors</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IServiceProvider? Services { get; private set; }
|
public IServiceProvider? ServiceProvider { get; private set; }
|
||||||
private MainWindow window;
|
private MainWindow window;
|
||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
|
|
@ -91,19 +91,17 @@ public partial class App : Application
|
||||||
services.AddSingleton(sessionStatus);
|
services.AddSingleton(sessionStatus);
|
||||||
services.AddTransient<SessionStatusBanner>();
|
services.AddTransient<SessionStatusBanner>();
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
ServiceProvider = services.BuildServiceProvider();
|
||||||
|
|
||||||
// Bind the canonical Settings to the static accessor so any
|
// Bind the canonical Settings to the static accessor so any
|
||||||
// code path that can't easily take a constructor parameter
|
// code path that can't easily take a constructor parameter
|
||||||
// (designer surfaces, Avalonia data templates) still gets
|
// (designer surfaces, Avalonia data templates) still gets
|
||||||
// the same instance the rest of the app is using. Idempotent:
|
// the same instance the rest of the app is using. Idempotent:
|
||||||
// re-binding from a second App boot (tests) is a no-op.
|
// re-binding from a second App boot (tests) is a no-op.
|
||||||
Settings.BindToServiceProvider(provider);
|
Settings.BindToServiceProvider(ServiceProvider);
|
||||||
|
|
||||||
Services = provider;
|
|
||||||
|
|
||||||
DataTemplates.Clear();
|
DataTemplates.Clear();
|
||||||
DataTemplates.Add(new ViewLocator(provider));
|
DataTemplates.Add(new ViewLocator(ServiceProvider));
|
||||||
|
|
||||||
// Wire the Settings singleton onto the SettingsPage singleton
|
// Wire the Settings singleton onto the SettingsPage singleton
|
||||||
// once, at composition time. The page is registered as a
|
// 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
|
// DataContext, and the TwoWay bindings inside the page keep
|
||||||
// mutating the same in-memory Settings instance that the rest
|
// mutating the same in-memory Settings instance that the rest
|
||||||
// of the app reads (OidcClientOptions construction, etc.).
|
// 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-
|
// Settings.DarkMode was previously a dead field: it round-
|
||||||
// tripped through the settings file and the SettingsPage
|
// tripped through the settings file and the SettingsPage
|
||||||
|
|
@ -134,8 +132,8 @@ public partial class App : Application
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
var homePage = provider.GetRequiredService<HomePage>();
|
var homePage = ServiceProvider.GetRequiredService<HomePage>();
|
||||||
homePage.DataContext = provider.GetRequiredService<HomePageViewModel>();
|
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
|
||||||
|
|
||||||
window = new MainWindow();
|
window = new MainWindow();
|
||||||
window.SessionBanner.DataContext = sessionStatus;
|
window.SessionBanner.DataContext = sessionStatus;
|
||||||
|
|
@ -155,8 +153,8 @@ public partial class App : Application
|
||||||
{
|
{
|
||||||
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
||||||
var nav = w.NavRoot;
|
var nav = w.NavRoot;
|
||||||
var hp = provider.GetRequiredService<HomePage>();
|
var hp = ServiceProvider.GetRequiredService<HomePage>();
|
||||||
hp.DataContext = provider.GetRequiredService<HomePageViewModel>();
|
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
|
||||||
_ = nav.PopToRootAsync();
|
_ = nav.PopToRootAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -187,7 +185,7 @@ public partial class App : Application
|
||||||
sessionStatus.OpenSettingsRequested += () =>
|
sessionStatus.OpenSettingsRequested += () =>
|
||||||
{
|
{
|
||||||
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
||||||
var settingsPage = provider.GetRequiredService<SettingsPage>();
|
var settingsPage = ServiceProvider.GetRequiredService<SettingsPage>();
|
||||||
var stack = w.NavRoot.NavigationStack;
|
var stack = w.NavRoot.NavigationStack;
|
||||||
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
|
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
|
||||||
{
|
{
|
||||||
|
|
@ -196,13 +194,13 @@ public partial class App : Application
|
||||||
_ = w.NavRoot.PushAsync(settingsPage);
|
_ = w.NavRoot.PushAsync(settingsPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.Opened += async (_, _) => await BootAsync(provider, api);
|
window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
|
||||||
}
|
}
|
||||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||||
{
|
{
|
||||||
singleView.MainView = new MainWindow
|
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()
|
public static async Task PushMainPageAsync()
|
||||||
{
|
{
|
||||||
var app = (App)Current;
|
var app = (App)Current;
|
||||||
var mainVm = app.Services.GetRequiredService<MainPageViewModel>();
|
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
|
||||||
var mainPage = app.Services.GetRequiredService<MainPage>();
|
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
|
||||||
mainPage.DataContext = mainVm;
|
mainPage.DataContext = mainVm;
|
||||||
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
|
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,37 @@
|
||||||
using System;
|
using System;
|
||||||
|
using Yavsc.Abstract.Identity;
|
||||||
|
using Yavsc.Abstract.Identity.Security;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
|
||||||
namespace PostIt.Models;
|
namespace PostIt.Models;
|
||||||
|
|
||||||
public class BlogPost
|
public class BlogPost : IBlogPost
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public string AuthorId { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public string? Article { get; set; }
|
public IApplicationUser Author { get; set; }
|
||||||
public string? Photo { get; set; }
|
|
||||||
public string? AuthorId { get; set; }
|
public string Article { get; set ; }
|
||||||
public DateTime DateCreated { get; set; }
|
public string Photo { get; set ; }
|
||||||
public string? UserCreated { get; set; }
|
public long Id { get; set ; }
|
||||||
public DateTime DateModified { get; set; }
|
public DateTime DateCreated { get; set ; }
|
||||||
public string? UserModified { 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PostIt;
|
using PostIt;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
@ -7,6 +8,7 @@ public class HomePageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public YavscApiClient Api { get; }
|
public YavscApiClient Api { get; }
|
||||||
public Settings Settings { get; }
|
public Settings Settings { get; }
|
||||||
|
public SessionStatusViewModel SessionStatus { get; }
|
||||||
|
|
||||||
private string _welcomeText = "Welcome to PostIt!";
|
private string _welcomeText = "Welcome to PostIt!";
|
||||||
public string WelcomeText
|
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 CanNavigateNext { get => true; protected set => throw new System.NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => false; 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;
|
Api = api;
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
|
SessionStatus = sessionStatus;
|
||||||
|
|
||||||
}
|
}
|
||||||
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
|
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -33,5 +37,8 @@ public class HomePageViewModel : ViewModelBase
|
||||||
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
/// (thread-safe dispatcher marshalling on PropertyChanged) — a
|
||||||
/// designer-only duplicate instance is therefore harmless.
|
/// designer-only duplicate instance is therefore harmless.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HomePageViewModel() : this(null!, new Settings()) { }
|
public HomePageViewModel() : this(null!, new Settings(), new SessionStatusViewModel())
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
<Button Content="Open Blog Interface"
|
<Button Content="Open Blog Interface"
|
||||||
Command="{Binding OpenBlogs}"
|
Command="{Binding OpenBlogs}"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public partial class MainPage : ContentPage
|
||||||
// Resolve via the App's DI container so the page gets
|
// Resolve via the App's DI container so the page gets
|
||||||
// the canonical services (Api client, settings, ...).
|
// the canonical services (Api client, settings, ...).
|
||||||
var app = Application.Current as App;
|
var app = Application.Current as App;
|
||||||
var services = app?.Services;
|
var services = app?.ServiceProvider;
|
||||||
if (services is null) return;
|
if (services is null) return;
|
||||||
|
|
||||||
var page = services.GetRequiredService<SignaturePage>();
|
var page = services.GetRequiredService<SignaturePage>();
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
src/Yavsc.Abstract/Blogspot/IBlogPost.cs
Normal file
14
src/Yavsc.Abstract/Blogspot/IBlogPost.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Yavsc.Abstract/Blogspot/IBlogPostPayLoad.cs
Normal file
9
src/Yavsc.Abstract/Blogspot/IBlogPostPayLoad.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Yavsc.Blogspot
|
||||||
|
{
|
||||||
|
public interface IBlogPostPayLoad
|
||||||
|
{
|
||||||
|
string Article { get; set; }
|
||||||
|
string Photo { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
|
using Yavsc.Interfaces;
|
||||||
|
|
||||||
namespace Yavsc.Abstract.Identity.Security
|
namespace Yavsc.Abstract.Identity.Security
|
||||||
{
|
{
|
||||||
public interface ICircleAuthorized
|
public interface ICircleAuthorized : ITaggable<long>
|
||||||
{
|
{
|
||||||
long Id { get; set; }
|
|
||||||
string AuthorId { get; }
|
string AuthorId { get; }
|
||||||
|
|
||||||
bool AuthorizeCircle(long circleId);
|
bool AuthorizeCircle(long circleId);
|
||||||
|
|
||||||
ICircleAuthorization [] GetACL();
|
ICircleAuthorization [] GetACL();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
namespace Yavsc.Interfaces
|
namespace Yavsc.Interfaces
|
||||||
{
|
{
|
||||||
public interface ITaggable<K>
|
public interface ITaggable<K> : IIdentified<K>
|
||||||
{
|
{
|
||||||
string [] GetTags();
|
string [] GetTags();
|
||||||
|
|
||||||
K Id { get; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -15,7 +15,6 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/activity")]
|
[Route("api/activity")]
|
||||||
[AllowAnonymous]
|
|
||||||
public class ActivityApiController : Controller
|
public class ActivityApiController : Controller
|
||||||
{
|
{
|
||||||
private ApplicationDbContext _context;
|
private ApplicationDbContext _context;
|
||||||
|
|
@ -88,7 +87,7 @@ namespace Yavsc.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/ActivityApi
|
// POST: api/ActivityApi
|
||||||
[HttpPost,Authorize("AdministratorOnly")]
|
[HttpPost, Authorize("AdministratorOnly")]
|
||||||
public async Task<IActionResult> PostActivity([FromBody] Activity activity)
|
public async Task<IActionResult> PostActivity([FromBody] Activity activity)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Yavsc.Helpers;
|
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Identity;
|
using Yavsc.Models.Identity;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
[Authorize, Route("~/api/gcm")]
|
[Authorize, Route("~/api/gcm")]
|
||||||
public class NativeConfidentialController : Controller
|
public class NativeConfidentialController : Controller
|
||||||
{
|
{
|
||||||
|
|
|
||||||
156
src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal file
156
src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
namespace Yavsc.Blogs.Tests;
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
@ -20,6 +22,7 @@ namespace Yavsc.Blogs.Tests;
|
||||||
/// header (or sending a token signed with the wrong key) gets a
|
/// header (or sending a token signed with the wrong key) gets a
|
||||||
/// 401 back from the framework.
|
/// 401 back from the framework.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Collection("JwtClaimMapping")]
|
||||||
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
{
|
{
|
||||||
private readonly BlogsWebServerFixture _fixture;
|
private readonly BlogsWebServerFixture _fixture;
|
||||||
|
|
@ -148,6 +151,85 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
|
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]
|
[Fact]
|
||||||
public async Task GetBlog_returns_401_when_no_token_is_provided()
|
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.
|
// The list should now be empty.
|
||||||
var listResponse = await http.GetAsync("/api/v1/blog");
|
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());
|
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
8
src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
8
src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
|
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
|
||||||
|
public sealed class JwtClaimMappingCollection
|
||||||
|
{
|
||||||
|
}
|
||||||
109
src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
Normal file
109
src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Exceptions;
|
using Yavsc.Server.Exceptions;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Yavsc.Org.Tests/Controllers/CommentsControllerTests.cs
Normal file
63
src/Yavsc.Org.Tests/Controllers/CommentsControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,15 +33,11 @@ public class TestUserStartupFilter : IStartupFilter
|
||||||
{
|
{
|
||||||
return app =>
|
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>();
|
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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"Site": {
|
"Site": {
|
||||||
"Authority": "https://localhost:5101",
|
"Authority": "https://localhost:5101",
|
||||||
|
"Audience": ["blogs"],
|
||||||
"Title": "Yavsc dev",
|
"Title": "Yavsc dev",
|
||||||
"Slogan": "Yavsc : WIP.",
|
"Slogan": "Yavsc : WIP.",
|
||||||
"Banner": "/images/yavsc.png",
|
"Banner": "/images/yavsc.png",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using Yavsc.Models.Blog;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Yavsc.Server.Exceptions;
|
using Yavsc.Server.Exceptions;
|
||||||
using Yavsc.Server.Helpers;
|
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
|
// 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
|
try
|
||||||
{
|
{
|
||||||
var blog = await blogSpotService.Details(User, id.Value);
|
var blog = await blogSpotService.Details(User, id.Value);
|
||||||
ViewBag.apicmtctlr = "/api/blogcomments";
|
ViewBag.apicmtctlr = "/api/v1/blogcomments";
|
||||||
ViewBag.moderatoFlag = User.IsInMsRole(YavscConstants.BlogModeratorGroupName);
|
ViewBag.moderatoFlag = User.IsInMsRole(YavscConstants.BlogModeratorGroupName);
|
||||||
|
|
||||||
return View(blog);
|
return View(blog);
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc.Helpers;
|
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Comment some post.
|
/// Comment some post.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Route("~/api/v1/blogcomments")]
|
||||||
public class CommentsController : Controller
|
public class CommentsController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
@ -21,7 +22,68 @@ namespace Yavsc.Controllers
|
||||||
_context = context;
|
_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
|
// GET: Comments
|
||||||
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var applicationDbContext = _context.Comment.Include(c => c.Post);
|
var applicationDbContext = _context.Comment.Include(c => c.Post);
|
||||||
|
|
@ -45,19 +107,24 @@ namespace Yavsc.Controllers
|
||||||
return View(comment);
|
return View(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: Comments/Create
|
// GET: Comments/Create (MVC form endpoint)
|
||||||
|
[HttpGet("form")]
|
||||||
public IActionResult Create()
|
public IActionResult Create()
|
||||||
{
|
{
|
||||||
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Post");
|
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: Comments/Create
|
// POST: Comments/Create (MVC form endpoint)
|
||||||
[HttpPost]
|
[HttpPost("form")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Create(Comment comment)
|
public async Task<IActionResult> Create(Comment comment)
|
||||||
{
|
{
|
||||||
comment.UserCreated = User.GetUserId();
|
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)
|
if (ModelState.IsValid)
|
||||||
{
|
{
|
||||||
|
|
@ -65,7 +132,7 @@ namespace Yavsc.Controllers
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
return RedirectToAction("Index");
|
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);
|
return View(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +149,7 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
return NotFound();
|
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);
|
return View(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +164,7 @@ namespace Yavsc.Controllers
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
return RedirectToAction("Index");
|
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);
|
return View(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route("api/dimiss")]
|
[Route("api/v1/dimiss")]
|
||||||
public class DimissClicksApiController : Controller
|
public class DimissClicksApiController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
|
||||||
|
|
@ -1189,6 +1189,8 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
static void LoadGoogleConfig(IConfigurationRoot configuration)
|
static void LoadGoogleConfig(IConfigurationRoot configuration)
|
||||||
{
|
{
|
||||||
string? googleClientFile = configuration["Authentication:Google:GoogleWebClientJson"];
|
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());
|
Config.GServiceAccount = JsonConvert.DeserializeObject<GoogleServiceAccount>(safile.OpenText().ReadToEnd());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
|
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
|
||||||
bool enableDirectoryBrowsing = false)
|
bool enableDirectoryBrowsing = false)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc;
|
using Yavsc;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Blog;
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Exceptions;
|
using Yavsc.Server.Exceptions;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ namespace Yavsc.ViewComponents
|
||||||
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id);
|
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id);
|
||||||
if (comment == null)
|
if (comment == null)
|
||||||
throw new InvalidOperationException();
|
throw new InvalidOperationException();
|
||||||
ViewBag.apictlr = "/api/blogcomments";
|
ViewBag.apictlr = "/api/v1/blogcomments";
|
||||||
return View("Default", comment);
|
return View("Default", comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<script src="~/js/comment.js" asp-append-version="true"></script>
|
<script src="~/js/comment.js" asp-append-version="true"></script>
|
||||||
<script>
|
<script>
|
||||||
$.psc.blogcomment.prototype.options.lang = '@System.Globalization.CultureInfo.CurrentUICulture.Name';
|
$.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.authorId = '@User.GetUserId()';
|
||||||
$.psc.blogcomment.prototype.options.authorName = '@User.GetUserName()';
|
$.psc.blogcomment.prototype.options.authorName = '@User.GetUserName()';
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
ReceiverId: @Model.Id
|
ReceiverId: @Model.Id
|
||||||
}),
|
}),
|
||||||
error: function(xhr,data) {
|
error: function(xhr,data) {
|
||||||
if (xhr.status=400)
|
if (xhr.status === 400)
|
||||||
{
|
{
|
||||||
if (xhr.responseJSON)
|
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>';
|
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())
|
$('#comments').append($(nnode).blogcomment())
|
||||||
},
|
},
|
||||||
url:'/api/blogcomments'
|
url:'/api/v1/blogcomments'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
@model IEnumerable<IBlogPost>
|
@model IEnumerable<IBlogPost>
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = "Blogs, l'index";
|
ViewBag.Title = "Blogs, l'index";
|
||||||
|
|
@ -63,13 +64,13 @@
|
||||||
posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")
|
posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")
|
||||||
@if ((post.DateModified - post.DateCreated).Minutes > 0){
|
@if ((post.DateModified - post.DateCreated).Minutes > 0){
|
||||||
@:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")
|
@:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")
|
||||||
})
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actiongroup">
|
<div class="actiongroup">
|
||||||
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
|
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
|
||||||
{
|
{
|
||||||
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
|
<a asp-action="Details" asp-route-id="@((IBlogPost)post).Id" class="btn btn-light">Details</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@using Microsoft.AspNetCore.Mvc.Localization
|
@using Microsoft.AspNetCore.Mvc.Localization
|
||||||
@using Yavsc
|
@using Yavsc
|
||||||
|
@using Yavsc.Blogspot
|
||||||
@using Yavsc.Models
|
@using Yavsc.Models
|
||||||
@using Yavsc.Models.Musical;
|
@using Yavsc.Models.Musical;
|
||||||
@using Yavsc.Models.Drawing;
|
@using Yavsc.Models.Drawing;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ var notifClick =
|
||||||
function(nid) {
|
function(nid) {
|
||||||
if (nid > 0) {
|
if (nid > 0) {
|
||||||
$.get({
|
$.get({
|
||||||
url: '/api/dimiss/click/' + nid,
|
url: '/api/v1/dimiss/click/' + nid,
|
||||||
success: $('div[data-nid='+nid+']').remove()
|
success: $('div[data-nid='+nid+']').remove()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ namespace Yavsc.Server.Helpers
|
||||||
|
|
||||||
public static string GetUserId(this ClaimsPrincipal user)
|
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)
|
public static string GetUserName(this ClaimsPrincipal user)
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,14 @@ using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Yavsc.Abstract.Identity;
|
using Yavsc.Abstract.Identity;
|
||||||
using Yavsc.Abstract.Identity.Security;
|
using Yavsc.Abstract.Identity.Security;
|
||||||
using Yavsc.Interfaces;
|
|
||||||
using Yavsc.Models.Access;
|
using Yavsc.Models.Access;
|
||||||
using Yavsc.Models.Relationship;
|
using Yavsc.Models.Relationship;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
|
||||||
namespace Yavsc.Models.Blog
|
namespace Yavsc.Models.Blog
|
||||||
{
|
{
|
||||||
|
|
||||||
public class BlogPost :
|
public class BlogPost : IBlogPost
|
||||||
IBlogPost, ICircleAuthorized, ITaggable<long>
|
|
||||||
{
|
{
|
||||||
[Key(), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
[Key(), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
[Display(Name = "Identifiant du post")]
|
[Display(Name = "Identifiant du post")]
|
||||||
|
|
@ -36,7 +35,7 @@ namespace Yavsc.Models.Blog
|
||||||
public string? AuthorId { get; set; }
|
public string? AuthorId { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Auteur")]
|
[Display(Name = "Auteur")]
|
||||||
public virtual ApplicationUser? Author { set; get; }
|
public virtual ApplicationUser Author { set; get; }
|
||||||
|
|
||||||
|
|
||||||
[Display(Name = "Date de création")]
|
[Display(Name = "Date de création")]
|
||||||
|
|
@ -96,6 +95,6 @@ namespace Yavsc.Models.Blog
|
||||||
[InverseProperty("Post")]
|
[InverseProperty("Post")]
|
||||||
public virtual List<Comment> Comments { get; set; }
|
public virtual List<Comment> Comments { get; set; }
|
||||||
|
|
||||||
IApplicationUser IBlogPost.Author { get => this.Author; }
|
IApplicationUser IBlogPost.Author => Author;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using Yavsc.Server.Helpers;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.ViewModels.Auth;
|
using Yavsc.ViewModels.Auth;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Yavsc.Blogspot;
|
||||||
|
|
||||||
public class BlogSpotService
|
public class BlogSpotService
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue