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:
|
||||
|
||||
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
|
||||
|
|
|
|||
128
.github/workflows/docker-publish-android.yml
vendored
128
.github/workflows/docker-publish-android.yml
vendored
|
|
@ -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
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.
|
||||
-->
|
||||
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
|
||||
<NoWarn>NU1701, NU1901, NU1902</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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.Services;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Models;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
HorizontalAlignment="Center"/>
|
||||
<Button Content="Open Blog Interface"
|
||||
Command="{Binding OpenBlogs}"
|
||||
HorizontalAlignment="Center"/>
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
</StackPanel>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
public interface ICircleAuthorized
|
||||
public interface ICircleAuthorized : ITaggable<long>
|
||||
{
|
||||
long Id { get; set; }
|
||||
|
||||
string AuthorId { get; }
|
||||
|
||||
bool AuthorizeCircle(long circleId);
|
||||
|
||||
ICircleAuthorization [] GetACL();
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
namespace Yavsc.Interfaces
|
||||
{
|
||||
public interface ITaggable<K>
|
||||
public interface ITaggable<K> : IIdentified<K>
|
||||
{
|
||||
string [] GetTags();
|
||||
|
||||
K Id { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
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.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());
|
||||
}
|
||||
|
||||
|
|
|
|||
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.Mvc;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Exceptions;
|
||||
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 =>
|
||||
{
|
||||
// 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"Site": {
|
||||
"Authority": "https://localhost:5101",
|
||||
"Audience": ["blogs"],
|
||||
"Title": "Yavsc dev",
|
||||
"Slogan": "Yavsc : WIP.",
|
||||
"Banner": "/images/yavsc.png",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue