Compare commits
85 changed files with 912 additions and 1882 deletions
|
|
@ -19,21 +19,21 @@ on:
|
|||
required: false
|
||||
type: boolean
|
||||
push:
|
||||
branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
|
||||
branches: [ "main", "release/*" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
|
||||
steps:
|
||||
- name: Clone yavsc
|
||||
run: |
|
||||
set -e
|
||||
cd /src
|
||||
git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
|
||||
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||
cd _src
|
||||
if [ -n "${GITHUB_REF:-}" ]; then
|
||||
git fetch origin "$GITHUB_REF"
|
||||
|
|
@ -49,6 +49,12 @@ jobs:
|
|||
run: |
|
||||
echo "🏗️ Building the solution..."
|
||||
cd /src/_src && dotnet build --verbosity normal
|
||||
- name: Install SkiaSharp native dependencies
|
||||
run: |
|
||||
echo "📦 Installing libfontconfig/libfreetype for SkiaSharp (Avalonia.Headless)..."
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
libfontconfig1 libfreetype6 libexpat1 zlib1g libbz2-1.0 libpng16-16 libbrotli1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
- name: Test
|
||||
run: |
|
||||
echo "🚀 Lancement des tests..."
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
release:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
|
||||
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
|
||||
steps:
|
||||
- name: Clone du repo au tag demandé
|
||||
env:
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
cd /src
|
||||
|
||||
if [[ ! -d _src/.git ]]; then
|
||||
git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
|
||||
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||
fi
|
||||
|
||||
cd _src
|
||||
|
|
@ -175,6 +175,12 @@ jobs:
|
|||
run: |
|
||||
cd /src/_src
|
||||
dotnet restore
|
||||
- name: Test
|
||||
run: |
|
||||
cd /src/_src && dotnet test \
|
||||
--verbosity normal \
|
||||
--filter="Category!=Platform-Android" \
|
||||
--logger "xunit;LogFileName=test-results.xml"
|
||||
|
||||
- name: Build de PostIt.Android ARM64
|
||||
run: |
|
||||
|
|
|
|||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,45 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.8-rc15] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Saisie et envoi de pieces jointes sur un billet de blog: le client monte les fichiers en `multipart/form-data` lors du `Save`, ajoute un lien Markdown par piece jointe a l'article, puis re-PUT le billet. Une collection `DraftAttachments` porte les fichiers en cours d'edition.
|
||||
* [PostIt] Apercu Markdown dans l'editeur de blogs (integration `MarkdownViewer.Core`) avec bascule edition/preview sur `BlogsPage`.
|
||||
* [PostIt] Gestion des devis: page de liste `EstimateListPage` (vues client et prestataire), page d'edition `EstimateEditionPage` avec lignes de devis (`EstimateLineItemViewModel`), et flux de validation d'un devis.
|
||||
* [PostIt] Flux des requetes en cours cote prestataire (`ProviderOngoingRequestsPage`) avec tri persistant.
|
||||
* [Yavsc.Api] Endpoints devis `GET /api/v1/estimate/asclient` et `GET /api/v1/estimate/asprovider` pour lister les devis en cours selon le role.
|
||||
* [Yavsc.Api] Endpoint `GET /api/v1/billing/provider/ongoing` et `POST /api/v1/billing/prosign/{billingCode}/{id}` pour le flux prestataire.
|
||||
* [Yavsc.Api.Client] `EstimateApiClient` + DTOs (`EstimateDto`, `EstimateLineDto`) pour le pipeline devis cote client lourd.
|
||||
* [Yavsc.Api.Client] Reprise de l'upload de fichiers blog dans `BlogApiClient` (`CreateMultipartContent`, `BlogUploadFile`) — la meme forme filaire que PostIt utilise en production.
|
||||
* [Yavsc.Abstract] `FileServerUrlHelpers` pour deriver les URLs publiques des fichiers utilisateur depuis l'autorite OIDC (alignement sur `UserFilesPath`).
|
||||
* [Yavsc.Server] `BlogSpotService.AttachFiles` : ecrit les pieces jointes sous la racine des fichiers utilisateur (`{UserFilesDirName}/{user}/blogs/{postId}/{fileName}`).
|
||||
* [Yavsc.Blogs] `BlogApiController` accepte `multipart/form-data` (champ `blog` + parts `file`) sur `POST` et `PUT /api/v1/blogspot`, en plus du payload JSON existant.
|
||||
* [PostIt] Variable d'environnement `POSTIT_SETTINGS_JSON` pour surcharger les parametres au lancement (utile en CI/test).
|
||||
* [Yavsc.Server] Migration EF `fileACL`: `CircleAuthorizationToFile` gagne `OwnerId` + `Access` et un graphe proprieetaire, base du controle d'acces fichier par cercle.
|
||||
* [Yavsc.Server] Migration EF `genericEstimate`: `Estimate.Query` passe de `RdvQuery` a `NominativeServiceCommand?` pour supporter des devis non lies a un RDV.
|
||||
* [Yavsc.Server] Migrations EF `NominativeServiceCommand` et `AddHairCutQueryLocationId` (localisation de la coupe).
|
||||
* [CI] Integration du scan de secrets Gitleaks dans le pipeline Forgejo + `.gitleaksignore`.
|
||||
* [Tests] Non-regressions: round-trip multipart blog (`BlogApiTests`, `CreateMultipartContentTests` forme + serveur), `BlogAttachmentLinkTests`, `FileServerUrlHelpersTests`, `EstimateApiControllerTests`, VMs `EstimateList`/`EstimateEdition`/`ProviderOngoingRequests`, `BillingControllerTests` (filtrage par utilisateur), `GetOpenIdConfiguration` contre l'hote de test.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Renommage `MainPage` -> `BlogsPage` (et `PushMainPageAsync` -> `PushBlogsPageAsync`) pour refletter le role de la page.
|
||||
* [PostIt] Refacto des `SiteSettings` : reorganisation des parametres, nouvelles valeurs par defaut, et indications plus claires dans les `appsettings`.
|
||||
* [Yavsc.Api] `BillingController` ne liste plus que les codes billing de l'utilisateur authentifie (filtre par `UserId`), au lieu de l'ensemble des requetes prestataire.
|
||||
* [Yavsc.Api] Mutualisation d'un `YavscMessageSender` partage dans l'hote API (au lieu d'une instance par controller).
|
||||
* [Yavsc.Org] La detection du doublon d'email a l'inscription utilise desormais le code erreur generic `DbException` 23505 (fournisseur-agnostique) au lieu de `PostgresException`, pour rester correct en test SQLite.
|
||||
* [PostIt] `YavscApiClient` tolere les corps vides (`204 No Content` sur `PUT /blogspot/{id}`) au lieu de lever un `JsonException`.
|
||||
* [CI] Le pipeline compile avant les tests, installe les dependances natives, et retire le `ItemGroup.Using Include=Xunit` superflu.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [PostIt] Suppression d'un faux message d'erreur affiche apres un `PUT /blogspot/{id}` reussi (204 traite comme succes, plus comme une reponse vide invalide).
|
||||
* [PostIt] Correction de la creation des liens de pieces jointes (`TryAppendAttachmentLinks`) : segment proprietaire et chemin `blogs/{id}/{fileName}` alignes sur ce que `AttachFiles` ecrit reellement.
|
||||
* [Yavsc.Blogs] Correction de l'upload / de la persistance des pieces jointes (`FileSystemHelpers` + `BlogApiController` multipart).
|
||||
* [Tests] Stabilisation des suites: seeds billing avec metadonnees d'audit, `GetOpenIdConfiguration_returns_ok` contre l'hote de test, retraits d'assertions abusives, tests SQLite verts.
|
||||
* [PostIt] Nettoyage de fichiers temporaires accidentels et ignore des repertoires `tmp` dans le depot.
|
||||
|
||||
## [1.0.8-rc14] - unstable
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -2,16 +2,5 @@
|
|||
<PropertyGroup>
|
||||
<RootNamespace>Yavsc</RootNamespace>
|
||||
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn>
|
||||
<!-- Disable the StaticWebAssets build cache (the *.dswa.cache.json
|
||||
files written under obj/.../ by DefineStaticWebAssets).
|
||||
ReadOrCreateCache opens them with File.OpenRead and no
|
||||
try/catch, so under parallel solution builds a sharing
|
||||
violation / TOCTOU race on the cache file escapes as
|
||||
MSB4018 and fails the whole build (seen on Yavsc.Blogs
|
||||
during the rc15 solution build). The cache is a pure
|
||||
build-speed optimization; turning it off has no effect on
|
||||
output correctness, only on incremental rebuild speed of
|
||||
the (wwwroot-light) web projects. -->
|
||||
<StaticWebAssetsCacheDefineStaticWebAssetsEnabled>false</StaticWebAssetsCacheDefineStaticWebAssetsEnabled>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -4,19 +4,22 @@
|
|||
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
|
||||
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
|
||||
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
|
||||
|
||||
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
|
||||
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
|
||||
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
|
||||
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
||||
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
|
||||
|
|
@ -34,9 +37,13 @@
|
|||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
||||
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
|
||||
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
|
||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
|
||||
|
|
@ -46,4 +53,4 @@
|
|||
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
|
||||
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,35 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<Import
|
||||
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<!-- PostIt-product-specific versions -->
|
||||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
|
||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
|
||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AndroidResource Include="Icon.png">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Browser" />
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
|
|
|||
|
|
@ -62,17 +62,4 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
|
|||
});
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
/// <summary>Multipart overload: BlogApiClient routes
|
||||
/// Create/UpdatePostAsync with attachments through here.
|
||||
/// Without this override the call would fall through to the
|
||||
/// base <see cref="YavscApiClient"/> and attempt a real HTTP
|
||||
/// round-trip against the stub authority. The body is a
|
||||
/// factory, so we record the delegate itself; tests that
|
||||
/// care about the multipart shape invoke it.</summary>
|
||||
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
{
|
||||
_recorder.Calls.Add((method, path, contentFactory));
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
using Yavsc.Blogspot;
|
||||
using Yavsc.Api.Client;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the markdown link that <see cref="BlogsViewModel.SaveAsync"/>
|
||||
/// appends to the article after a successful attachment upload.
|
||||
///
|
||||
/// <para>The link must be an absolute URL derived from the OIDC
|
||||
/// authority via <c>FileServerUrlHelpers.GetUserFilesUri</c>
|
||||
/// (Yavsc.Abstract): the <c>/files</c> file server is hosted by
|
||||
/// Yavsc.Org — the IdP — not by the blogs API host, so a link
|
||||
/// built from <c>BlogsApiUrl</c> (or left relative) 404s when the
|
||||
/// reader opens the post. The regression this guards: a generated
|
||||
/// link of the shape <c>/files/{user}/blogs/{id}/{name}</c>
|
||||
/// (relative, authority-less) instead of
|
||||
/// <c>https://{authority}/files/{user}/blogs/{id}/{name}</c>.</para>
|
||||
/// </summary>
|
||||
public class BlogAttachmentLinkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
|
||||
{
|
||||
const string authority = "https://idp.example.test";
|
||||
|
||||
var recorder = new CallRecorder();
|
||||
var api = new RecordingYavscApiClient(recorder);
|
||||
var blog = new BlogApiClient(api, "https://blogs.example.test/api/v1/");
|
||||
var settings = new Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = authority,
|
||||
ClientId = "stub",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
};
|
||||
var vm = new BlogsViewModel(blog, settings);
|
||||
|
||||
// Update branch: an existing post is selected, the user
|
||||
// edits the buffers and attaches a file.
|
||||
vm.SelectedPost = new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
AuthorId = "tester",
|
||||
Title = "Avant",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
vm.DraftTitle = "Après";
|
||||
vm.DraftArticle = "Contenu modifié.";
|
||||
vm.DraftAttachments.Add(new BlogUploadFile(
|
||||
"note.txt",
|
||||
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
||||
"text/plain"));
|
||||
|
||||
await vm.SaveAsync();
|
||||
|
||||
// The update branch issues two PUTs: the multipart one
|
||||
// carrying the file, then — because TryAppendAttachmentLinks
|
||||
// appended a link — a JSON one carrying the final article.
|
||||
var linkPut = recorder.Calls.LastOrDefault(
|
||||
c => c.method == HttpMethod.Put);
|
||||
Assert.NotEqual(default, linkPut);
|
||||
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
|
||||
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
|
||||
|
||||
var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
|
||||
var sentStringContent = multipartContent.First() as StringContent ;
|
||||
|
||||
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
|
||||
// The regression shape: a relative, authority-less link.
|
||||
Assert.DoesNotContain("](/files/", sentString);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
using System.Net.Http;
|
||||
using PostIt.ViewModels;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class EstimateListPageViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Client_perspective_calls_asclient_endpoint()
|
||||
{
|
||||
var api = new StubEstimateListApi();
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
||||
Assert.Equal("Mes devis à valider", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
// Most recent first
|
||||
Assert.Equal(2, vm.Estimates[0].Id);
|
||||
Assert.Equal(1, vm.Estimates[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_perspective_calls_asprovider_endpoint()
|
||||
{
|
||||
var api = new StubEstimateListApi();
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
||||
Assert.Equal("Mes devis en attente", vm.Title);
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterText_filters_by_title_and_counterpart()
|
||||
{
|
||||
var api = new StubEstimateListApi();
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.FilterText = "plomberie";
|
||||
Assert.Single(vm.Estimates);
|
||||
Assert.Equal(1, vm.Estimates[0].Id);
|
||||
|
||||
vm.FilterText = "BOB";
|
||||
Assert.Single(vm.Estimates);
|
||||
Assert.Equal("bob", vm.Estimates[0].ClientId);
|
||||
|
||||
vm.FilterText = string.Empty;
|
||||
Assert.Equal(2, vm.Estimates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_result_sets_an_info_status()
|
||||
{
|
||||
var api = new StubEstimateListApi { Empty = true };
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
|
||||
|
||||
await vm.InitializeAsync();
|
||||
|
||||
Assert.Empty(vm.Estimates);
|
||||
Assert.Contains("Aucun devis", vm.StatusMessage);
|
||||
}
|
||||
|
||||
private sealed class StubEstimateListApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
public List<string> Paths { get; } = new();
|
||||
public bool Empty { get; init; }
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add(path);
|
||||
|
||||
if (typeof(T) == typeof(List<EstimateDto>))
|
||||
{
|
||||
var items = Empty
|
||||
? new List<EstimateDto>()
|
||||
: new List<EstimateDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Devis plomberie",
|
||||
Description = "Réparation fuite",
|
||||
ClientId = "bob",
|
||||
OwnerId = "alice",
|
||||
CommandType = "Rdv",
|
||||
Bill = new List<EstimateLineDto>
|
||||
{
|
||||
new() { Name = "Main d'œuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2,
|
||||
Title = "Devis électricité",
|
||||
Description = "Mise aux normes",
|
||||
ClientId = "carol",
|
||||
OwnerId = "alice",
|
||||
CommandType = "Rdv",
|
||||
Bill = new List<EstimateLineDto>(),
|
||||
},
|
||||
};
|
||||
return Task.FromResult((T)(object)items);
|
||||
}
|
||||
|
||||
return Task.FromResult(default(T)!);
|
||||
}
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Paths.Add(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<BillingQueryDetailsPage>();
|
||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||
services.AddTransient<EstimateEditionPage>();
|
||||
services.AddTransient<EstimateListPage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
|
@ -33,7 +33,6 @@
|
|||
<PackageReference Include="Mapsui.Avalonia12" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="MarkdownViewer.Core" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -194,7 +194,10 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
|
||||
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
|
||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
|
||||
return dto!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -212,31 +215,10 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
|
||||
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialise the response body as <typeparamref name="T"/>,
|
||||
/// tolerating an empty body. <c>PUT /blogspot/{id}</c> answers
|
||||
/// <c>204 No Content</c>: without this guard,
|
||||
/// <c>JsonSerializer.DeserializeAsync</c> throws
|
||||
/// <c>JsonException: The input does not contain any JSON
|
||||
/// tokens</c> and the VM surfaces a successful save as an
|
||||
/// error. Callers that genuinely need a payload
|
||||
/// (<c>GetPostAsync</c>, <c>CreatePostAsync</c>) are unaffected:
|
||||
/// their endpoints always return a body.
|
||||
/// </summary>
|
||||
private static async Task<T> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return default!;
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return default!;
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
|
||||
return dto!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ public class ViewLocator : IDataTemplate
|
|||
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
|
||||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Point de vue d'une liste de devis en cours (non encore validés par
|
||||
/// le client).
|
||||
/// </summary>
|
||||
public enum EstimateListPerspective
|
||||
{
|
||||
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
|
||||
Client,
|
||||
|
||||
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
|
||||
Provider,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liste des devis en cours servie par <c>GET api/v1/estimate/asclient</c>
|
||||
/// ou <c>GET api/v1/estimate/asprovider</c> selon la perspective choisie.
|
||||
/// </summary>
|
||||
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly EstimateApiClient _estimateClient;
|
||||
private readonly EstimateListPerspective _perspective;
|
||||
private List<EstimateDto> _allEstimates = new();
|
||||
|
||||
public EstimateListPerspective Perspective => _perspective;
|
||||
|
||||
public string Title => _perspective == EstimateListPerspective.Client
|
||||
? "Mes devis à valider"
|
||||
: "Mes devis en attente";
|
||||
|
||||
public string Subtitle => _perspective == EstimateListPerspective.Client
|
||||
? "Point de vue client"
|
||||
: "Point de vue fournisseur";
|
||||
|
||||
/// <summary>
|
||||
/// Libellé de la colonne « autre partie » : côté client on montre
|
||||
/// le fournisseur (OwnerId), côté fournisseur on montre le client.
|
||||
/// </summary>
|
||||
public string CounterpartLabel => _perspective == EstimateListPerspective.Client
|
||||
? "Fournisseur"
|
||||
: "Client";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<EstimateDto> Estimates { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string FilterText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial EstimateDto? SelectedEstimate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusMessage { get; set; } = "Chargement des devis...";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des devis...");
|
||||
|
||||
public override bool CanNavigateNext
|
||||
{
|
||||
get => false;
|
||||
protected set { _ = value; }
|
||||
}
|
||||
|
||||
public override bool CanNavigatePrevious
|
||||
{
|
||||
get => true;
|
||||
protected set { _ = value; }
|
||||
}
|
||||
|
||||
public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
|
||||
{
|
||||
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
||||
_perspective = perspective;
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => RefreshAsync();
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var items = (_perspective == EstimateListPerspective.Client
|
||||
? await _estimateClient.GetOngoingEstimatesAsClientAsync().ConfigureAwait(true)
|
||||
: await _estimateClient.GetOngoingEstimatesAsProviderAsync().ConfigureAwait(true))
|
||||
?? new List<EstimateDto>();
|
||||
|
||||
_allEstimates = items
|
||||
.OrderByDescending(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
ApplyFilter();
|
||||
this.SetInfoStatus(_allEstimates.Count == 0
|
||||
? "Aucun devis en cours."
|
||||
: $"{_allEstimates.Count} devis en cours chargé(s).");
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
_allEstimates = new List<EstimateDto>();
|
||||
Estimates = new ObservableCollection<EstimateDto>();
|
||||
this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_allEstimates = new List<EstimateDto>();
|
||||
Estimates = new ObservableCollection<EstimateDto>();
|
||||
this.SetErrorStatus($"Erreur: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnFilterTextChanged(string value)
|
||||
{
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
var query = FilterText?.Trim();
|
||||
var filtered = string.IsNullOrWhiteSpace(query)
|
||||
? _allEstimates
|
||||
: _allEstimates.Where(x =>
|
||||
ContainsInsensitive(x.Title, query)
|
||||
|| ContainsInsensitive(x.Description, query)
|
||||
|| ContainsInsensitive(x.CommandType, query)
|
||||
|| ContainsInsensitive(x.ClientId, query)
|
||||
|| ContainsInsensitive(x.OwnerId, query))
|
||||
.ToList();
|
||||
|
||||
Estimates = new ObservableCollection<EstimateDto>(filtered);
|
||||
}
|
||||
|
||||
private static bool ContainsInsensitive(string? value, string query)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
|
@ -80,9 +80,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
[ObservableProperty]
|
||||
public partial Settings Settings { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsMarkdownViewerVisible { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
internal async Task RefreshAsync()
|
||||
{
|
||||
|
|
@ -119,7 +116,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|
||||
// Build a fresh BlogPostDto from the editor buffer on
|
||||
// every Save — we no longer mutate SelectedPost in
|
||||
// place. The previous behavior copied the buffer
|
||||
// place. The previous behaviour copied the buffer
|
||||
// (which was a no-op when SelectedPost was null)
|
||||
// back onto the model and relied on a
|
||||
// [Required] violation to surface the missing
|
||||
|
|
@ -163,7 +160,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
}
|
||||
else
|
||||
{
|
||||
TryAppendAttachmentLinks(SelectedPost, attachments);
|
||||
var update = new BlogPostDto
|
||||
{
|
||||
Id = SelectedPost.Id,
|
||||
|
|
@ -174,7 +170,24 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
DateCreated = SelectedPost.DateCreated,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
|
||||
|
||||
if (TryAppendAttachmentLinks(SelectedPost, attachments))
|
||||
{
|
||||
var linkUpdate = new BlogPostDto
|
||||
{
|
||||
Id = SelectedPost.Id,
|
||||
AuthorId = SelectedPost.AuthorId,
|
||||
Photo = SelectedPost.Photo,
|
||||
Title = DraftTitle,
|
||||
Article = DraftArticle ?? string.Empty,
|
||||
DateCreated = SelectedPost.DateCreated,
|
||||
DateModified = DateTime.UtcNow,
|
||||
};
|
||||
await BlogClient.UpdatePostAsync(SelectedPost.Id, linkUpdate);
|
||||
}
|
||||
|
||||
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
|
||||
DraftAttachments.Clear();
|
||||
}
|
||||
|
|
@ -361,7 +374,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
|
||||
public BlogsViewModel()
|
||||
{
|
||||
WindowTitle = "Blog";
|
||||
SettingsModel = new Settings();
|
||||
Init(SettingsModel);
|
||||
BlogClient = null;
|
||||
|
|
@ -377,7 +389,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
this.SetInfoStatus("Prêt.");
|
||||
Settings = settings ?? new Settings();
|
||||
SearchText = Settings.SearchText;
|
||||
IsMarkdownViewerVisible = false;
|
||||
WindowTitle = "PostIt";
|
||||
DraftTitle = string.Empty;
|
||||
DraftArticle = string.Empty;
|
||||
|
|
@ -424,7 +435,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
|||
/// </summary>
|
||||
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
|
||||
{
|
||||
WindowTitle = "Blog";
|
||||
SettingsModel = new Settings();
|
||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
|
||||
Services = services;
|
||||
|
|
|
|||
|
|
@ -32,15 +32,11 @@ public class HomePageViewModel : ViewModelBase
|
|||
|
||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
|
||||
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
||||
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||
}
|
||||
public IAsyncRelayCommand OpenBlogs { get; }
|
||||
public IAsyncRelayCommand OpenActivities { get; }
|
||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||
|
||||
private async Task OpenActivitiesAsync()
|
||||
{
|
||||
|
|
@ -76,25 +72,6 @@ public class HomePageViewModel : ViewModelBase
|
|||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
private async Task OpenEstimateListAsync(EstimateListPerspective perspective)
|
||||
{
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var estimateClient = app.ServiceProvider?.GetRequiredService<EstimateApiClient>();
|
||||
if (estimateClient is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client devis indisponible.");
|
||||
}
|
||||
|
||||
var vm = new EstimateListPageViewModel(estimateClient, perspective);
|
||||
await vm.InitializeAsync();
|
||||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia designer constructor. Builds a self-contained VM
|
||||
/// with a freshly-constructed Settings so the XAML preview can
|
||||
|
|
|
|||
|
|
@ -33,9 +33,6 @@ public partial class Settings : ViewModelBase
|
|||
[ObservableProperty]
|
||||
public partial string SearchText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsMarkdownViewerVisible { get; set; } = false;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:dto="using:Yavsc.Api.Client"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
x:Class="PostIt.Views.EstimateListPage"
|
||||
x:DataType="vm:EstimateListPageViewModel"
|
||||
Header="{Binding Title}">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
|
||||
<StackPanel Grid.Row="0" Spacing="3">
|
||||
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding Subtitle}" Opacity="0.75" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,*" Margin="0,10,0,8">
|
||||
<Button Grid.Column="0" Content="Rafraîchir" Command="{Binding RefreshCommand}" />
|
||||
<TextBox Grid.Column="2"
|
||||
PlaceholderText="Filtrer: titre, description, type, partie..."
|
||||
Text="{Binding FilterText, Mode=TwoWay}" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2"
|
||||
ItemsSource="{Binding Estimates}"
|
||||
SelectedItem="{Binding SelectedEstimate, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="dto:EstimateDto">
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#22000000"
|
||||
CornerRadius="8"
|
||||
Padding="10"
|
||||
Margin="0,0,0,8">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="{Binding Title}"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Id}"
|
||||
FontSize="11"
|
||||
Opacity="0.75"
|
||||
HorizontalAlignment="Right" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding Description}"
|
||||
FontSize="11"
|
||||
Opacity="0.75"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding Bill.Count}"
|
||||
FontSize="11"
|
||||
Opacity="0.65"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<postitControls:StatusBar Grid.Column="0"
|
||||
DataContext="{Binding ActionStatus}" />
|
||||
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class EstimateListPage : ContentPage
|
||||
{
|
||||
public EstimateListPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
xmlns:models="using:Yavsc.Blogspot"
|
||||
xmlns:views="using:PostIt.Views.Blogs"
|
||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="PostIt.Views.Blogs.BlogsPage"
|
||||
x:DataType="vm:BlogsViewModel"
|
||||
|
|
@ -22,8 +21,6 @@
|
|||
Padding="5"
|
||||
Margin="5"
|
||||
>
|
||||
|
||||
|
||||
<StackPanel HorizontalAlignment="Stretch">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
|
||||
<TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
|
||||
|
|
@ -73,12 +70,6 @@
|
|||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarToggleButton Label="Markdown Viewer"
|
||||
IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}">
|
||||
<CommandBarToggleButton.Icon>
|
||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||
</CommandBarToggleButton.Icon>
|
||||
</CommandBarToggleButton>
|
||||
<CommandBarButton x:Name="OpenSignatureDevButton"
|
||||
Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
|
||||
<CommandBarButton.Icon>
|
||||
|
|
@ -94,12 +85,15 @@
|
|||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<WrapPanel Grid.Row="0" ItemSpacing="10">
|
||||
|
||||
</WrapPanel>
|
||||
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
|
||||
<ListBox ItemsSource="{Binding FilteredPosts}"
|
||||
SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:BlogPostDto">
|
||||
<StackPanel Spacing="4" >
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
|
||||
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
|
||||
|
|
@ -109,16 +103,12 @@
|
|||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<md:MarkdownViewer Grid.Row="2" Name="MarkdownViewer"
|
||||
IsVisible="{Binding IsMarkdownViewerVisible}"
|
||||
MarkdownText="{Binding DraftArticle}" />
|
||||
|
||||
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8"
|
||||
IsVisible="{Binding !IsMarkdownViewerVisible}">
|
||||
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8">
|
||||
<Grid RowSpacing="10"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
|
|
|
|||
|
|
@ -26,13 +26,5 @@
|
|||
Command="{Binding OpenProviderRequests}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes devis à valider"
|
||||
Command="{Binding OpenClientEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes devis en attente"
|
||||
Command="{Binding OpenProviderEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
</StackPanel>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ namespace Yavsc.Server.Helpers
|
|||
{
|
||||
public static class AbstractFileSystemHelpers
|
||||
{
|
||||
public static string UserBillsDirName { set; get; }
|
||||
public static string UserFilesDirName { set; get; }
|
||||
|
||||
/// <summary>
|
||||
/// Is Valid this Path?
|
||||
|
|
@ -62,6 +64,17 @@ namespace Yavsc.Server.Helpers
|
|||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static UserDirectoryInfo GetUserFiles(string userId, string subdir)
|
||||
{
|
||||
UserDirectoryInfo di = new UserDirectoryInfo(UserFilesDirName, userId, subdir);
|
||||
return di;
|
||||
}
|
||||
public static bool IsRegularFile(string userName, string subdir)
|
||||
{
|
||||
FileInfo fi = new FileInfo( Path.Combine(Path.Combine(UserFilesDirName, userName), subdir));
|
||||
return fi.Exists;
|
||||
}
|
||||
|
||||
|
||||
// Server side only supports POSIX file systems
|
||||
public const char RemoteDirectorySeparator = '/';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -99,18 +99,7 @@ public sealed class BlogApiClient
|
|||
return await _api.CallAsync<BlogPostDto?>(method, path, () => CreateMultipartContent(post, files), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>multipart/form-data</c> body PostIt
|
||||
/// sends when a blog post is saved with one or more
|
||||
/// attachments: a <c>blog</c> field holding the
|
||||
/// <paramref name="post"/> serialised camelCase with
|
||||
/// <c>WhenWritingNull</c>, plus one <c>file</c> part per entry
|
||||
/// in <paramref name="files"/> (each carrying its
|
||||
/// <see cref="BlogUploadFile.ContentType"/>, falling back to
|
||||
/// <c>application/octet-stream</c> when unset). Exposed as
|
||||
/// <c>public</c> so the blogs backend tests can assert on the
|
||||
/// wire shape directly and round-trip it through the live
|
||||
/// <c>BlogsWebServerFixture</c> host.</summary>
|
||||
public static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> files)
|
||||
private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection<BlogUploadFile> files)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
|
||||
|
|
|
|||
|
|
@ -55,22 +55,6 @@ public sealed class EstimateApiClient
|
|||
return _api.CallAsync<EstimateDto>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists the ongoing estimates (not yet validated by the client) where
|
||||
/// the current user is the client who initiated the request
|
||||
/// (<c>GET estimate/asclient</c>).
|
||||
/// </summary>
|
||||
public Task<List<EstimateDto>> GetOngoingEstimatesAsClientAsync(CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/asclient"), ct: ct);
|
||||
|
||||
/// <summary>
|
||||
/// Lists the ongoing estimates (not yet validated by the client)
|
||||
/// established by the current user as the provider
|
||||
/// (<c>GET estimate/asprovider</c>).
|
||||
/// </summary>
|
||||
public Task<List<EstimateDto>> GetOngoingEstimatesAsProviderAsync(CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<EstimateDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/asprovider"), ct: ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an estimate. When <see cref="EstimateDto.CommandId"/> is set,
|
||||
/// the server also stamps the linked command as validated.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
<Library>true</Library>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||
|
|
|
|||
|
|
@ -206,110 +206,4 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
|||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an estimate owned by alice (the provider) for the given
|
||||
/// client, optionally already validated by the client.
|
||||
/// </summary>
|
||||
private long SeedEstimate(string clientId, bool clientValidated)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
var estimate = new Estimate
|
||||
{
|
||||
ClientId = clientId,
|
||||
OwnerId = "alice",
|
||||
CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis prestation",
|
||||
Description = "Devis de test",
|
||||
AttachedFiles = new List<string>(),
|
||||
AttachedGraphics = new List<string>(),
|
||||
Bill = new List<CommandLine>
|
||||
{
|
||||
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
||||
},
|
||||
ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
db.SaveChanges();
|
||||
return estimate.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOngoingEstimatesAsClient_returns_only_the_current_users_ongoing_estimates()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
|
||||
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
|
||||
SeedEstimate(clientId: "alice", clientValidated: false); // another client's: excluded
|
||||
|
||||
using var http = NewClient("bob");
|
||||
|
||||
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var items = doc.RootElement.EnumerateArray().ToArray();
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
|
||||
Assert.Equal("bob", item.GetProperty("clientId").GetString());
|
||||
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
|
||||
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
|
||||
|
||||
using var http = NewClient("alice");
|
||||
|
||||
var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var items = doc.RootElement.EnumerateArray().ToArray();
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
|
||||
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
||||
Assert.Single(item.GetProperty("bill").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOngoingEstimatesAsClient_without_token_is_unauthorized()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
|
||||
|
||||
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOngoingEstimatesAsProvider_without_token_is_unauthorized()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
|
||||
|
||||
var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@
|
|||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Yavsc.Api.Test</RootNamespace>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
|
|
@ -21,6 +18,7 @@
|
|||
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
|
||||
|
|
@ -28,7 +26,8 @@
|
|||
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ namespace Yavsc.ApiControllers
|
|||
|
||||
private readonly ILogger logger;
|
||||
private readonly IBillingService billingService;
|
||||
private readonly SiteSettings siteSettings;
|
||||
|
||||
public BillingController(
|
||||
IAuthorizationService authorizationService,
|
||||
|
|
@ -44,8 +43,7 @@ namespace Yavsc.ApiControllers
|
|||
ApplicationDbContext context,
|
||||
IOptions<GoogleAuthSettings> googleSettings,
|
||||
IYavscMessageSender GCMSender,
|
||||
IBillingService billingService,
|
||||
IOptions<SiteSettings> siteSettings
|
||||
IBillingService billingService
|
||||
)
|
||||
{
|
||||
_googleSettings=googleSettings.Value;
|
||||
|
|
@ -55,7 +53,6 @@ namespace Yavsc.ApiControllers
|
|||
this._localizer = SR;
|
||||
_GCMSender=GCMSender;
|
||||
this.billingService=billingService;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
[HttpGet("facture-{billingCode}-{id}.pdf"), Authorize]
|
||||
|
|
@ -68,7 +65,7 @@ namespace Yavsc.ApiControllers
|
|||
return new ChallengeResult();
|
||||
}
|
||||
|
||||
var fi = bill.GetBillInfo(billingService, siteSettings);
|
||||
var fi = bill.GetBillInfo(billingService);
|
||||
|
||||
if (!fi.Exists) return Ok(new { Error = "Not generated" });
|
||||
return File(fi.OpenRead(), "application/x-pdf", fi.Name);
|
||||
|
|
@ -232,7 +229,7 @@ namespace Yavsc.ApiControllers
|
|||
}
|
||||
if (Request.Form.Files.Count!=1)
|
||||
return new BadRequestResult();
|
||||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro", siteSettings);
|
||||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro");
|
||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
// Notify the client
|
||||
|
|
@ -276,7 +273,7 @@ namespace Yavsc.ApiControllers
|
|||
}
|
||||
|
||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
||||
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
||||
FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
|
||||
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
||||
return File(fi.OpenRead(), "application/x-pdf", filename); ;
|
||||
}
|
||||
|
|
@ -313,7 +310,7 @@ namespace Yavsc.ApiControllers
|
|||
}
|
||||
if (Request.Form.Files.Count!=1)
|
||||
return new BadRequestResult();
|
||||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
|
||||
await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli");
|
||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
|
||||
|
|
@ -330,9 +327,9 @@ namespace Yavsc.ApiControllers
|
|||
}
|
||||
|
||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
||||
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
||||
FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
|
||||
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
||||
return File(fi.OpenRead(), "application/x-pdf", filename);
|
||||
return File(fi.OpenRead(), "application/x-pdf", filename); ;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -408,8 +405,7 @@ namespace Yavsc.ApiControllers
|
|||
FileReceivedInfo fi;
|
||||
try
|
||||
{
|
||||
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload,
|
||||
siteSettings, token);
|
||||
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload, token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -198,28 +198,6 @@ namespace Yavsc.Controllers
|
|||
return Ok(estimate);
|
||||
}
|
||||
|
||||
// GET: api/estimate/asclient
|
||||
// Ongoing estimates (not yet validated by the client) where the
|
||||
// current user is the client who initiated the request.
|
||||
[HttpGet("asclient")]
|
||||
public IActionResult GetOngoingEstimatesAsClient()
|
||||
{
|
||||
var uid = User.GetUserId();
|
||||
return Ok(_context.Estimates.Include(e => e.Bill)
|
||||
.Where(e => e.ClientId == uid && e.ClientValidationDate == default));
|
||||
}
|
||||
|
||||
// GET: api/estimate/asprovider
|
||||
// Ongoing estimates (not yet validated by the client) established
|
||||
// by the current user as the provider.
|
||||
[HttpGet("asprovider")]
|
||||
public IActionResult GetOngoingEstimatesAsProvider()
|
||||
{
|
||||
var uid = User.GetUserId();
|
||||
return Ok(_context.Estimates.Include(e => e.Bill)
|
||||
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ImageMagick;
|
||||
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Api.Helpers;
|
||||
using Yavsc.Server.Helpers;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Yavsc.WebApi.Controllers
|
||||
{
|
||||
|
|
@ -28,16 +28,13 @@ namespace Yavsc.WebApi.Controllers
|
|||
];
|
||||
|
||||
readonly ApplicationDbContext _dbContext;
|
||||
private readonly SiteSettings siteSettings;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ApiAccountController(
|
||||
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
ILoggerFactory loggerFactory, ApplicationDbContext dbContext)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
|
||||
_dbContext = dbContext;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
|
|
@ -149,7 +146,7 @@ namespace Yavsc.WebApi.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var info = user.ReceiveAvatar(avatarFile, siteSettings);
|
||||
var info = user.ReceiveAvatar(avatarFile);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ namespace Yavsc.Api.Helpers
|
|||
public static class RequestHelpers
|
||||
{
|
||||
// Check for some apache proxy header, if any
|
||||
#pragma warning disable CS8632 // L'annotation pour les types référence Nullable doit être utilisée uniquement dans le code au sein d'un contexte d'annotations '#nullable'.
|
||||
public static string? ForwardedFor(this HttpRequest request) {
|
||||
string? host = request.Headers["X-Forwarded-For"];
|
||||
#pragma warning restore CS8632 // L'annotation pour les types référence Nullable doit être utilisée uniquement dans le code au sein d'un contexte d'annotations '#nullable'.
|
||||
if (string.IsNullOrEmpty(host)) {
|
||||
host = request.Host.Value;
|
||||
} else { // Using X-Forwarded-For last address
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ internal class Program
|
|||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddConfiguration("api");
|
||||
Config.SiteSetup = builder.Configuration.GetSection("Site").Get<SiteSettings>() ?? new SiteSettings();
|
||||
|
||||
var services = builder.Services;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
{
|
||||
"Site": {
|
||||
"Authority": "https://yavsc.pschneider.fr",
|
||||
"Authority": "https://localhost:5001",
|
||||
"Audience": ["api"],
|
||||
"CorsAllowedOrigins": [
|
||||
"https://yavsc.pschneider.fr",
|
||||
"https://blogs.pschneider.fr"
|
||||
],
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp"
|
||||
"https://localhost:5003",
|
||||
"https://yavsc.pschneider.fr"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ IClassFixture<MappedClaimsBlogsWebServerFixture>
|
|||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet JWT re-mappé",
|
||||
Title = "Billet JWT remappe",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,11 @@ using System.Net.Http.Json;
|
|||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
|
||||
using NuGet.Protocol;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
|
|
@ -339,9 +336,10 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// AttachFiles writes under the fixture-configured
|
||||
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
|
||||
var filesRoot = _fixture.BlogFilesRoot;
|
||||
var previousRoot = AbstractFileSystemHelpers.UserFilesDirName;
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
AbstractFileSystemHelpers.UserFilesDirName = tempRoot;
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -364,9 +362,6 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken))!;
|
||||
|
||||
var attachmentCount = CountAttachmentsForPost(created.Id);
|
||||
Assert.True(attachmentCount == 0);
|
||||
|
||||
var update = new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
|
|
@ -377,10 +372,8 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var form = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(JsonSerializer.Serialize(update)), "blog" }
|
||||
};
|
||||
var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog");
|
||||
|
||||
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
|
|
@ -405,14 +398,13 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
using var detailsDoc = JsonDocument.Parse(
|
||||
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
|
||||
attachmentCount = CountAttachmentsForPost(created.Id);
|
||||
Assert.True(attachmentCount >= 1);
|
||||
|
||||
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// BlogFilesRoot is the fixture-shared temp root (one per
|
||||
// test run); leave it in place for the other multipart
|
||||
// tests. The OS reclaims it.
|
||||
AbstractFileSystemHelpers.UserFilesDirName = previousRoot;
|
||||
try { Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,137 +450,6 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment()
|
||||
{
|
||||
// Full PostIt "Save" flow for an existing post with a new
|
||||
// attachment, driven through the real BlogApiClient — the
|
||||
// same code PostIt runs — so any drift between the
|
||||
// client's wire shape and the controller's contract fails
|
||||
// here before it reaches a user:
|
||||
//
|
||||
// 1. BlogsViewModel.SaveAsync (update branch) calls
|
||||
// BlogApiClient.UpdatePostAsync(id, update, attachments)
|
||||
// with a non-empty attachments list →
|
||||
// multipart/form-data PUT built by
|
||||
// CreateMultipartContent: a "blog" field holding the
|
||||
// BlogPostDto serialised camelCase + WhenWritingNull,
|
||||
// plus one "file" part per attachment.
|
||||
// 2. TryAppendAttachmentLinks then appends a markdown
|
||||
// link per attachment to the article, and the VM
|
||||
// issues a second UpdatePostAsync without files →
|
||||
// plain JSON PUT.
|
||||
//
|
||||
// The test asserts the final state: the article carries
|
||||
// the markdown link, the attachment row is persisted, and
|
||||
// the file physically landed under the user-files root
|
||||
// the link points to.
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
|
||||
// AttachFiles writes under the fixture-configured
|
||||
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
|
||||
var filesRoot = _fixture.BlogFilesRoot;
|
||||
|
||||
try
|
||||
{
|
||||
// BlogApiClient takes the versioned base address
|
||||
// (…/api/v1/) and prepends its "blogspot" prefix —
|
||||
// exactly how PostIt wires it against production.
|
||||
var client = new BlogApiClient(
|
||||
new TestApiTransport(http),
|
||||
_fixture.ApiUrl(""));
|
||||
|
||||
// Seed the post through the same path PostIt uses for
|
||||
// a first Save on a new draft: CreatePostAsync without
|
||||
// files → plain JSON POST.
|
||||
var draft = new BlogPostDto
|
||||
{
|
||||
Title = "Avant PostIt",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var created = await client.CreatePostAsync(draft,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal("tester", created.AuthorId);
|
||||
|
||||
// Step 1 — the update DTO the VM builds from the
|
||||
// editor buffer (SelectedPost is the read-only hint
|
||||
// for Id, AuthorId, Photo and DateCreated), sent with
|
||||
// one attachment → multipart PUT.
|
||||
var update = new BlogPostDto
|
||||
{
|
||||
Id = created.Id,
|
||||
AuthorId = created.AuthorId,
|
||||
Photo = created.Photo,
|
||||
Title = "Modifié depuis PostIt",
|
||||
Article = "Contenu modifié.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var attachments = new[]
|
||||
{
|
||||
new BlogUploadFile(
|
||||
"note.txt",
|
||||
System.Text.Encoding.UTF8.GetBytes("payload test"),
|
||||
"text/plain")
|
||||
};
|
||||
|
||||
await client.UpdatePostAsync(created.Id, update, attachments,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Step 2 — TryAppendAttachmentLinks appended a
|
||||
// markdown link to the article (the payload does not
|
||||
// embed Author, so the owner segment falls back to
|
||||
// AuthorId), and the VM PUTs the article again,
|
||||
// without files → JSON path of UpdatePostAsync.
|
||||
var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt";
|
||||
var linkUpdate = new BlogPostDto
|
||||
{
|
||||
Id = created.Id,
|
||||
AuthorId = created.AuthorId,
|
||||
Photo = created.Photo,
|
||||
Title = update.Title,
|
||||
Article = update.Article + $"\n\n- [note.txt]({fileUrl})",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await client.UpdatePostAsync(created.Id, linkUpdate,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
|
||||
// Final state, read back through the client.
|
||||
var details = await client.GetPostAsync(created.Id,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(details);
|
||||
Assert.Equal("Modifié depuis PostIt", details!.Title);
|
||||
Assert.Contains($"- [note.txt]({fileUrl})", details.Article);
|
||||
|
||||
// The attachment row is persisted…
|
||||
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||
|
||||
// …and the file physically landed where the appended
|
||||
// markdown link points:
|
||||
// {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
|
||||
var expectedFile = Path.Combine(
|
||||
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
|
||||
var written = Directory.Exists(filesRoot)
|
||||
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
|
||||
: "<none>";
|
||||
Assert.True(File.Exists(expectedFile),
|
||||
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// BlogFilesRoot is the fixture-shared temp root (one per
|
||||
// test run); leave it in place for the other multipart
|
||||
// tests. The OS reclaims it.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,255 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Direct tests for <see cref="BlogApiClient.CreateMultipartContent"/>
|
||||
/// — the body builder PostIt runs when a post is saved with
|
||||
/// attachments. Two layers:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><description><b>Shape</b>: call the method with a sample
|
||||
/// <see cref="BlogPostDto"/> + a couple of
|
||||
/// <see cref="BlogUploadFile"/>s and assert the produced
|
||||
/// <see cref="MultipartFormDataContent"/> carries a <c>blog</c>
|
||||
/// part (camelCase JSON, <c>WhenWritingNull</c> so unset graph
|
||||
/// members are dropped) and one <c>file</c> part per attachment
|
||||
/// (right filename, right bytes, declared Content-Type, and the
|
||||
/// <c>application/octet-stream</c> fallback when the type is
|
||||
/// unset). This is the contract the controller's
|
||||
/// <c>ReadBlogRequestAsync</c> + <c>AttachFiles</c> rely on; if
|
||||
/// the wire shape drifts, the round-trip below fails first, but
|
||||
/// the shape assertions pin down <i>which</i> field broke.</description></item>
|
||||
/// <item><description><b>Round-trip</b>: POST the very content the
|
||||
/// method built to the live <see cref="BlogsWebServerFixture"/>
|
||||
/// host (authenticated as <c>tester</c>) and assert 201 Created
|
||||
/// + the attachment row persisted + the file physically landed
|
||||
/// under <c>{BlogFilesRoot}/tester/blogs/{id}/{fileName}</c>.
|
||||
/// This proves the shape the method produces is one the server
|
||||
/// actually accepts — not just well-formed multipart, but the
|
||||
/// contract <c>BlogApiController.PostBlog</c> binds.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Built on <see cref="BlogsWebServerFixture"/> for the round-trip
|
||||
/// leg (real SQLite FK enforcement, real <c>BlogSpotService</c>,
|
||||
/// real <c>AddJwtBearer</c> validating <see cref="TestTokenIssuer"/>
|
||||
/// tokens). The shape leg needs no host, but the class is
|
||||
/// <c>[Collection("Yavsc Blogs")]</c> + <c>IClassFixture</c> so the
|
||||
/// one shared host serves both legs without spinning a second one.
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class CreateMultipartContentTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public CreateMultipartContentTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>Reset the database and seed the
|
||||
/// <c>tester</c> <see cref="ApplicationUser"/> row — required
|
||||
/// for the round-trip POST, whose <c>BlogPost.AuthorId</c> is
|
||||
/// a FK to <c>AspNetUsers.Id</c> that SQLite enforces. See
|
||||
/// <see cref="BlogApiTests.ResetAndSeedDefaultUser"/> for the
|
||||
/// same rationale.</summary>
|
||||
private void ResetAndSeedDefaultUser()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("tester");
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "tester")
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
private int CountAttachmentsForPost(long postId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
return db.BlogAttachedFiles.Count(a => a.PostId == postId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_produces_blog_part_camelCase_with_WhenWritingNull_and_one_file_part_per_attachment()
|
||||
{
|
||||
// A DTO that exercises both branches of the serialiser:
|
||||
// filled scalar fields (title, article, id, dates, authorId)
|
||||
// must come through in camelCase; unset graph members
|
||||
// (Author, Tags, Photo) must be dropped by
|
||||
// WhenWritingNull — the controller's
|
||||
// ReadBlogRequestAsync would otherwise receive explicit
|
||||
// nulls that confuse the resource-based ownership check.
|
||||
var post = new BlogPostDto
|
||||
{
|
||||
Id = 42,
|
||||
AuthorId = "tester",
|
||||
Author = null,
|
||||
Title = "Billet multipart",
|
||||
Article = "Contenu du billet.",
|
||||
Photo = null,
|
||||
Tags = null,
|
||||
DateCreated = new DateTime(2026, 9, 18, 12, 0, 0, DateTimeKind.Utc),
|
||||
DateModified = new DateTime(2026, 9, 18, 12, 30, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var noteBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
|
||||
var binBytes = new byte[] { 0x00, 0xFF, 0x42 };
|
||||
var files = new BlogUploadFile[]
|
||||
{
|
||||
new("note.txt", noteBytes, "text/plain"),
|
||||
new("bin.dat", binBytes, null) // no ContentType → octet-stream fallback
|
||||
};
|
||||
|
||||
var content = BlogApiClient.CreateMultipartContent(post, files);
|
||||
|
||||
// --- Top-level shape: a multipart/form-data body. ---
|
||||
var form = Assert.IsAssignableFrom<MultipartFormDataContent>(content);
|
||||
Assert.Equal("multipart/form-data", form.Headers.ContentType?.MediaType);
|
||||
|
||||
// Index parts by their form field name. The "blog" field
|
||||
// is unique; the "file" field repeats once per attachment
|
||||
// (distinguished by ContentDisposition.FileName), so a
|
||||
// flat dictionary would collide — group instead.
|
||||
var byName = form
|
||||
.GroupBy(p => p.Headers.ContentDisposition?.Name?.Trim('"'))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
Assert.Contains("blog", byName.Keys);
|
||||
Assert.Single(byName["blog"]);
|
||||
|
||||
// --- "blog" part: camelCase JSON + WhenWritingNull. ---
|
||||
var blogJson = await byName["blog"][0].ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken);
|
||||
using var doc = JsonDocument.Parse(blogJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// camelCase: the serialiser used JsonNamingPolicy.CamelCase,
|
||||
// so the properties land as title/article/id/dateCreated/….
|
||||
Assert.True(root.TryGetProperty("title", out var titleEl));
|
||||
Assert.Equal(post.Title, titleEl.GetString());
|
||||
Assert.True(root.TryGetProperty("article", out var articleEl));
|
||||
Assert.Equal(post.Article, articleEl.GetString());
|
||||
Assert.True(root.TryGetProperty("id", out var idEl));
|
||||
Assert.Equal(post.Id, idEl.GetInt64());
|
||||
Assert.True(root.TryGetProperty("authorId", out var authorIdEl));
|
||||
Assert.Equal(post.AuthorId, authorIdEl.GetString());
|
||||
Assert.True(root.TryGetProperty("dateCreated", out _));
|
||||
Assert.True(root.TryGetProperty("dateModified", out _));
|
||||
|
||||
// WhenWritingNull: the null graph members are omitted, not
|
||||
// emitted as explicit nulls.
|
||||
Assert.False(root.TryGetProperty("author", out _),
|
||||
$"Expected 'author' to be omitted (WhenWritingNull), got: {blogJson}");
|
||||
Assert.False(root.TryGetProperty("tags", out _),
|
||||
$"Expected 'tags' to be omitted (WhenWritingNull), got: {blogJson}");
|
||||
Assert.False(root.TryGetProperty("photo", out _),
|
||||
$"Expected 'photo' to be omitted (WhenWritingNull), got: {blogJson}");
|
||||
|
||||
// --- "file" parts: one per attachment, right name, type,
|
||||
// and bytes. The "file" form field carries several parts;
|
||||
// collect them by filename. ---
|
||||
var fileParts = form
|
||||
.Where(p => p.Headers.ContentDisposition?.Name?.Trim('"') == "file")
|
||||
.ToDictionary(p => p.Headers.ContentDisposition?.FileName?.Trim('"'));
|
||||
Assert.Equal(2, fileParts.Count);
|
||||
|
||||
var notePart = fileParts["note.txt"];
|
||||
Assert.Equal("text/plain", notePart.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(noteBytes, await notePart.ReadAsByteArrayAsync(
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
// The attachment with no ContentType falls back to
|
||||
// application/octet-stream — the branch in
|
||||
// CreateMultipartContent that guards
|
||||
// string.IsNullOrWhiteSpace(file.ContentType).
|
||||
var binPart = fileParts["bin.dat"];
|
||||
Assert.Equal("application/octet-stream", binPart.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(binBytes, await binPart.ReadAsByteArrayAsync(
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_then_POST_round_trips_through_live_host_and_persists_attachment()
|
||||
{
|
||||
ResetAndSeedDefaultUser();
|
||||
using var http = NewClient(subject: "tester");
|
||||
var filesRoot = _fixture.BlogFilesRoot;
|
||||
|
||||
// Create-shape DTO (Id=0 → the controller treats it as a
|
||||
// create), mirroring what PostIt's MainPageViewModel.Save
|
||||
// builds for a first Save with an attachment.
|
||||
var draft = new BlogPostDto
|
||||
{
|
||||
Id = 0,
|
||||
AuthorId = "tester",
|
||||
Title = "Billet avec pièce jointe",
|
||||
Article = "Contenu du billet multipart.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
var attachments = new BlogUploadFile[]
|
||||
{
|
||||
new("note.txt", System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")
|
||||
};
|
||||
|
||||
// The exact body PostIt would send — built by the method
|
||||
// under test, not hand-rolled here.
|
||||
using var content = BlogApiClient.CreateMultipartContent(draft, attachments);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, _fixture.BlogSpotUrl())
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
var response = await http.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Dump the body on failure so the assertion message is
|
||||
// enough to start a fix (the framework default is opaque).
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Fail(
|
||||
$"Expected 201 Created, got {(int)response.StatusCode} {response.StatusCode}. Body: {body}");
|
||||
}
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<BlogPost>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.NotEqual(0, created!.Id);
|
||||
Assert.Equal("tester", created.AuthorId);
|
||||
|
||||
// The attachment row is persisted…
|
||||
Assert.True(CountAttachmentsForPost(created.Id) >= 1);
|
||||
|
||||
// …and the file physically landed where AttachFiles writes
|
||||
// it: {BlogFilesRoot}/{user}/blogs/{postId}/{fileName}.
|
||||
var expectedFile = Path.Combine(
|
||||
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
|
||||
var written = Directory.Exists(filesRoot)
|
||||
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
|
||||
: "<none>";
|
||||
Assert.True(File.Exists(expectedFile),
|
||||
$"Expected uploaded file at {expectedFile}. Actually written: {written}");
|
||||
}
|
||||
}
|
||||
|
|
@ -63,16 +63,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
|||
public long PostId { get; private set; }
|
||||
public string DefaultUserLogin { get => "alice"; }
|
||||
|
||||
/// <summary>Root directory under which
|
||||
/// <c>BlogSpotService.AttachFiles</c> writes uploaded files
|
||||
/// during tests (the configured <c>SiteSettings.Blog</c>).
|
||||
/// Attachments land at
|
||||
/// <c>{BlogFilesRoot}/{userName}/blogs/{postId}/{fileName}</c>.
|
||||
/// A fresh per-run temp directory, so tests can assert on the
|
||||
/// physical file without touching the developer's real
|
||||
/// user-files tree.</summary>
|
||||
public string BlogFilesRoot { get; private set; } = "files-root";
|
||||
|
||||
// A single SqliteConnection held open at the static level,
|
||||
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
|
||||
// shared configuration into static slots. Closing the
|
||||
|
|
@ -122,18 +112,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
|||
builder.Services.AddSingleton<IFileSystemAuthManager>(
|
||||
new NoopFileSystemAuthManager());
|
||||
|
||||
// BlogSpotService.AttachFiles writes uploaded files under
|
||||
// siteSettings.Blog. Without a configured SiteSettings the
|
||||
// IOptions<SiteSettings> value has Blog == null and
|
||||
// Path.Combine(null, …) throws inside AttachFiles' catch-all,
|
||||
// silently persisting no attachment. Point Blog at a
|
||||
// per-run temp directory so the multipart tests can assert
|
||||
// on real files.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.Blog = BlogFilesRoot;
|
||||
});
|
||||
|
||||
// Real BlogSpotService — same instance the production host
|
||||
// builds (ApplicationDbContext, IAuthorizationService,
|
||||
// IFileSystemAuthManager). With PermissionHandler registered
|
||||
|
|
|
|||
|
|
@ -39,18 +39,6 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
|||
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
|
||||
|
||||
builder.Services.AddSingleton<IFileSystemAuthManager>(new NoopFileSystemAuthManager());
|
||||
|
||||
// BlogSpotService's constructor throws InvalidOperationException
|
||||
// ("SiteSettings.Blog is not configured.") when Blog is null, so
|
||||
// every request hitting BlogApiController would 500 resolving the
|
||||
// service. These tests never upload files (JSON-only POST/PUT), so
|
||||
// AttachFiles short-circuits — but the service still has to be
|
||||
// constructible. Point Blog at a per-run temp directory, mirroring
|
||||
// BlogsWebServerFixture.
|
||||
builder.Services.Configure<SiteSettings>(s =>
|
||||
{
|
||||
s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
|
||||
});
|
||||
builder.Services.AddScoped<BlogSpotService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
builder.Services.AddControllers()
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace Yavsc.Blogs.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IYavscApiClient"/> transport for integration
|
||||
/// tests: it forwards <see cref="BlogApiClient"/> calls over a
|
||||
/// test-owned <see cref="HttpClient"/> (already authenticated by
|
||||
/// the caller) and applies the contract documented on the
|
||||
/// interface — JSON bodies serialised with web defaults
|
||||
/// (camelCase, case-insensitive reads), and an
|
||||
/// <see cref="HttpRequestException"/> carrying the response body
|
||||
/// on non-2xx statuses so assertion failures stay actionable.
|
||||
///
|
||||
/// <para>Unlike PostIt's <c>YavscApiClient</c>, there is no
|
||||
/// silent-refresh-on-401: the test token never expires mid-test,
|
||||
/// and a 401 is a failure we want surfaced, not retried.</para>
|
||||
///
|
||||
/// The <see cref="HttpClient"/> is owned by the test, not by this
|
||||
/// transport, so <see cref="DisposeAsync"/> is a no-op.
|
||||
/// </summary>
|
||||
public sealed class TestApiTransport : IYavscApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public TestApiTransport(HttpClient http)
|
||||
{
|
||||
Http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
}
|
||||
|
||||
public HttpClient Http { get; }
|
||||
|
||||
public Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
=> SendAsync<T>(method, path,
|
||||
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
|
||||
ct);
|
||||
|
||||
public Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
=> SendAsync<T>(method, path, contentFactory, ct);
|
||||
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
CancellationToken ct = default)
|
||||
=> await SendAsync<object>(method, path,
|
||||
body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
|
||||
ct);
|
||||
|
||||
public async Task CallAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent> contentFactory,
|
||||
CancellationToken ct = default)
|
||||
=> await SendAsync<object>(method, path, contentFactory, ct);
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
Func<HttpContent>? contentFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path);
|
||||
if (contentFactory is not null)
|
||||
request.Content = contentFactory();
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException(
|
||||
$"HTTP {(int)response.StatusCode} {response.StatusCode} on {method} {path}. Body: {error}");
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return default!; // 201/204 with an empty body (PUT returns 204).
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, JsonOptions)!;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Api.Client\Yavsc.Api.Client.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
|
||||
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
|
||||
|
|
|
|||
|
|
@ -15,14 +15,10 @@ namespace Yavsc.Blogs.Controllers
|
|||
public class BlogApiController : Controller
|
||||
{
|
||||
private readonly BlogSpotService blogSpotService;
|
||||
private readonly ILogger<BlogApiController> logger;
|
||||
|
||||
public BlogApiController(
|
||||
BlogSpotService blogSpotService,
|
||||
ILoggerFactory loggerFactory)
|
||||
public BlogApiController(BlogSpotService blogSpotService)
|
||||
{
|
||||
this.blogSpotService = blogSpotService;
|
||||
this.logger = loggerFactory.CreateLogger<BlogApiController>();
|
||||
}
|
||||
|
||||
// GET: api/v1/blogspot
|
||||
|
|
@ -61,30 +57,9 @@ namespace Yavsc.Blogs.Controllers
|
|||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> PutBlog(long id)
|
||||
{
|
||||
var blog = await ReadBlogRequestAsync();
|
||||
var blog = await ReadPutBlogRequestAsync();
|
||||
if (blog is null)
|
||||
{
|
||||
logger.LogWarning("PUT /api/v1/blogspot/{id} failed to read blog payload from request body or multipart form.", id);
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
// Without [FromBody], the data-annotations attributes
|
||||
// ([Required] on Title, etc.) are no longer evaluated by
|
||||
// the model binder — the body is read manually. Run
|
||||
// validation explicitly so an empty Title still yields
|
||||
// 400 (and not a 500 from the DB NOT NULL constraint).
|
||||
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(blog);
|
||||
var validationResults = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
|
||||
if (!System.ComponentModel.DataAnnotations.Validator.TryValidateObject(
|
||||
blog, validationContext, validationResults, validateAllProperties: true))
|
||||
{
|
||||
foreach (var result in validationResults)
|
||||
{
|
||||
ModelState.AddModelError(
|
||||
result.MemberNames.FirstOrDefault() ?? string.Empty,
|
||||
result.ErrorMessage ?? "Invalid value.");
|
||||
}
|
||||
logger.LogWarning("PUT /api/v1/blogspot/{id} failed validation: {errors}", id, ModelState);
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
|
|
@ -113,27 +88,15 @@ namespace Yavsc.Blogs.Controllers
|
|||
var existing = await blogSpotService.GetBlogPostAsync(id);
|
||||
if (existing == null)
|
||||
{
|
||||
logger.LogWarning("PUT /api/v1/blogspot/{id} failed: blog post not found.", id);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await blogSpotService.Modify(User, blog);
|
||||
logger.LogInformation("PUT /api/v1/blogspot/{id} modified successfully.", id);
|
||||
if (files.Count > 0)
|
||||
{
|
||||
logger.LogInformation("PUT /api/v1/blogspot/{id} attaching {fileCount} files.", id, files.Count);
|
||||
blogSpotService.AttachFiles(files, User.GetUserId(), id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("PUT /api/v1/blogspot/{id} no files to attach.", id);
|
||||
}
|
||||
await blogSpotService.Modify(User, blog, files);
|
||||
}
|
||||
catch (AuthorizationFailureException)
|
||||
{
|
||||
logger.LogWarning("PUT /api/v1/blogspot/{id} failed: authorization failure.", id);
|
||||
return Challenge();
|
||||
}
|
||||
|
||||
|
|
@ -142,14 +105,8 @@ namespace Yavsc.Blogs.Controllers
|
|||
|
||||
// POST: api/v1/blogspot
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PostBlog()
|
||||
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
|
||||
{
|
||||
var blog = await ReadBlogRequestAsync();
|
||||
if (blog is null)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
// These properties are server-managed or optional graph members and
|
||||
// should not block JSON payloads coming from API clients.
|
||||
ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
|
||||
|
|
@ -158,46 +115,35 @@ namespace Yavsc.Blogs.Controllers
|
|||
ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated));
|
||||
ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified));
|
||||
|
||||
// Without [FromBody], the data-annotations attributes
|
||||
// ([Required] on Title, etc.) are no longer evaluated by
|
||||
// the model binder — the body is read manually. Run
|
||||
// validation explicitly so an empty Title still yields
|
||||
// 400 (and not a 500 from the DB NOT NULL constraint).
|
||||
var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(blog);
|
||||
var validationResults = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
|
||||
if (!System.ComponentModel.DataAnnotations.Validator.TryValidateObject(
|
||||
blog, validationContext, validationResults, validateAllProperties: true))
|
||||
{
|
||||
foreach (var result in validationResults)
|
||||
{
|
||||
ModelState.AddModelError(
|
||||
result.MemberNames.FirstOrDefault() ?? string.Empty,
|
||||
result.ErrorMessage ?? "Invalid value.");
|
||||
}
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
// The BlogSpotService.Create() signature requires an
|
||||
// IFormFileCollection for file uploads. Reading
|
||||
// Request.Form.Files when the request is a plain JSON
|
||||
// body (e.g. from PostIt) throws
|
||||
// "This request does not have a Content-Type header.
|
||||
// Forms are available from requests with bodies like
|
||||
// POSTs and a form Content-Type of either
|
||||
// application/x-www-form-urlencoded or
|
||||
// multipart/form-data."
|
||||
//
|
||||
// Two valid use cases for this endpoint:
|
||||
// 1. JSON body only (no files) — PostIt path.
|
||||
// 2. multipart/form-data with a 'blog' field + 0..N
|
||||
// files — PostIt "create with attachments" path.
|
||||
// files — future browser / server-rendered path.
|
||||
//
|
||||
// Branch on HasFormContentType: pass the form files when
|
||||
// present, pass an empty collection otherwise. Reading
|
||||
// Request.Form.Files on a plain JSON request throws
|
||||
// "This request does not have a Content-Type header…",
|
||||
// and AttachFiles short-circuits on an empty collection.
|
||||
// present, pass an empty collection otherwise. The
|
||||
// FileSystem branch in BlogSpotService.Create then
|
||||
// short-circuits to "no files to handle".
|
||||
var files = Request.HasFormContentType
|
||||
? Request.Form.Files
|
||||
: (IFormFileCollection)new FormFileCollection();
|
||||
var uid = User.GetUserId();
|
||||
var post = blogSpotService.Create(uid, blog);
|
||||
blogSpotService.AttachFiles(files, uid, post.Id);
|
||||
var post = blogSpotService.Create(uid, blog, files);
|
||||
return CreatedAtRoute("GetBlog", new { id = post.Id },
|
||||
post.GetPayload());
|
||||
}
|
||||
|
|
@ -264,17 +210,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a <see cref="Models.Blog.BlogPost"/> from either a
|
||||
/// JSON body or a multipart/form-data request carrying the
|
||||
/// payload in a <c>blog</c> form field. Shared by
|
||||
/// <see cref="PostBlog"/> and <see cref="PutBlog"/>: both
|
||||
/// accept the two shapes so PostIt can attach files on
|
||||
/// create as well as on update. Returns <c>null</c> (with
|
||||
/// the reason recorded in <c>ModelState</c>) when the
|
||||
/// multipart form has no usable <c>blog</c> field.
|
||||
/// </summary>
|
||||
private async Task<Models.Blog.BlogPost?> ReadBlogRequestAsync()
|
||||
private async Task<Models.Blog.BlogPost?> ReadPutBlogRequestAsync()
|
||||
{
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ namespace Yavsc.Blogs.Controllers
|
|||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Abstract.Helpers;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
[Authorize,Route(APIPrefix + "/fs")]
|
||||
public partial class FileSystemApiController : Controller
|
||||
|
|
@ -20,18 +19,15 @@ namespace Yavsc.Blogs.Controllers
|
|||
readonly ApplicationDbContext dbContext;
|
||||
private readonly IAuthorizationService AuthorizationService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly SiteSettings siteSettings;
|
||||
|
||||
public FileSystemApiController(ApplicationDbContext context,
|
||||
IAuthorizationService authorizationService,
|
||||
ILoggerFactory loggerFactory,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
ILoggerFactory loggerFactory)
|
||||
|
||||
{
|
||||
AuthorizationService = authorizationService;
|
||||
dbContext = context;
|
||||
_logger = loggerFactory.CreateLogger<FileSystemApiController>();
|
||||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
[HttpGet()]
|
||||
|
|
@ -45,7 +41,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
{
|
||||
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
||||
// _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}");
|
||||
var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir);
|
||||
var files = AbstractFileSystemHelpers.GetUserFiles(User.GetUserId(), subdir);
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +53,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
List<FileReceivedInfo> received = new List<FileReceivedInfo>();
|
||||
InvalidPathException pathex = null;
|
||||
try {
|
||||
destDir = User.EnsureDestinationDirectory(subdir, siteSettings);
|
||||
destDir = User.EnsureDestinationDirectory(subdir);
|
||||
} catch (InvalidPathException ex) {
|
||||
pathex = ex;
|
||||
}
|
||||
|
|
@ -112,7 +108,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
var user = dbContext.Users.Single(
|
||||
u => u.Id == uid
|
||||
);
|
||||
var info = user.MoveUserFileToDir(query.Id, query.To, siteSettings);
|
||||
var info = user.MoveUserFileToDir(query.Id, query.To);
|
||||
if (!info.Done) return new BadRequestObjectResult(info);
|
||||
return Ok(new { moved = query.Id });
|
||||
}
|
||||
|
|
@ -135,11 +131,11 @@ namespace Yavsc.Blogs.Controllers
|
|||
try {
|
||||
if (Config.UserFilesOptions.FileProvider.GetFileInfo(Path.Combine(user.UserName, query.Id)).Exists)
|
||||
{
|
||||
var result = user.MoveUserFile(query.Id, query.To, siteSettings);
|
||||
var result = user.MoveUserFile(query.Id, query.To);
|
||||
if (!result.Done) return new BadRequestObjectResult(result);
|
||||
}
|
||||
else {
|
||||
var result = user.MoveUserDir(query.Id, query.To, siteSettings);
|
||||
var result = user.MoveUserDir(query.Id, query.To);
|
||||
if (!result.Done) return new BadRequestObjectResult(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -165,7 +161,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
);
|
||||
|
||||
try {
|
||||
var result = user.DeleteUserDirOrFile(id, siteSettings);
|
||||
var result = user.DeleteUserDirOrFile(id);
|
||||
if (!result.Done)
|
||||
return new BadRequestObjectResult(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ using Microsoft.AspNetCore.SignalR;
|
|||
using Yavsc.Server.Helpers;
|
||||
using static Yavsc.Constants;
|
||||
using Yavsc.Server.Hubs;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Yavsc.Blogs.Controllers
|
||||
{
|
||||
|
|
@ -22,20 +21,13 @@ namespace Yavsc.Blogs.Controllers
|
|||
private readonly IHubContext<ChatHub> hubContext;
|
||||
readonly ApplicationDbContext dbContext;
|
||||
|
||||
private readonly SiteSettings siteSettings;
|
||||
|
||||
public FileSystemStreamController(
|
||||
ApplicationDbContext context,
|
||||
ILiveProcessor liveProcessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
IHubContext<ChatHub> hubContext,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
public FileSystemStreamController(ApplicationDbContext context, ILiveProcessor liveProcessor, ILoggerFactory loggerFactory,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
this.dbContext = context;
|
||||
this.logger = loggerFactory.CreateLogger<FileSystemStreamController>();
|
||||
this.liveProcessor = liveProcessor;
|
||||
this.hubContext = hubContext;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
[Authorize, Route("put/{filename}")]
|
||||
|
|
@ -63,7 +55,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
);
|
||||
|
||||
|
||||
string destDir = HttpContext.User.EnsureDestinationDirectory(filePath, siteSettings);
|
||||
string destDir = HttpContext.User.EnsureDestinationDirectory(filePath);
|
||||
logger.LogInformation($"Saving flow to {destDir}");
|
||||
var userId = User.GetUserId();
|
||||
var user = await dbContext.Users.FirstAsync(u => u.Id == userId);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ using Yavsc.Models;
|
|||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
namespace Yavsc.Blogs;
|
||||
|
||||
internal class Program
|
||||
|
|
@ -23,8 +22,6 @@ internal class Program
|
|||
|
||||
var services = builder.Services;
|
||||
|
||||
var siteSection = builder.Configuration.GetSection("Site");
|
||||
services.Configure<SiteSettings>(siteSection);
|
||||
// MvcBuilder
|
||||
builder.Services
|
||||
.AddAuthorization(options =>
|
||||
|
|
@ -82,17 +79,8 @@ internal class Program
|
|||
options.SetDefaultCulture(supportedCultures[0])
|
||||
.AddSupportedCultures(supportedCultures)
|
||||
.AddSupportedUICultures(supportedCultures);
|
||||
})
|
||||
.Configure<SiteSettings>(settings =>
|
||||
{
|
||||
if (settings.Blog == null)
|
||||
{
|
||||
settings.Blog = "blog";
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// App startup
|
||||
using (var app = builder.Build())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@
|
|||
"Authority": "https://localhost:5001",
|
||||
"Audience": ["blogs"],
|
||||
"CorsAllowedOrigins": [
|
||||
"https://localhost:5005",
|
||||
"https://api.pschneider.fr"
|
||||
],
|
||||
"Blog": "blog"
|
||||
"https://localhost:5005"
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ namespace Yavsc.Org.Tests;
|
|||
public class EstimateSignatureFileHelperTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
private SiteSettings siteSettings;
|
||||
|
||||
public EstimateSignatureFileHelperTests()
|
||||
{
|
||||
|
|
@ -29,7 +28,7 @@ public class EstimateSignatureFileHelperTests : IDisposable
|
|||
Path.GetTempPath(),
|
||||
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
this.siteSettings = new SiteSettings { Blog = _tempRoot };
|
||||
AbstractFileSystemHelpers.UserFilesDirName = _tempRoot;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -72,7 +71,7 @@ public class EstimateSignatureFileHelperTests : IDisposable
|
|||
};
|
||||
|
||||
// Act
|
||||
var fi = Run(user, 123L, SignatureType.Pro, payload, this.siteSettings);
|
||||
var fi = Run(user, 123L, SignatureType.Pro, payload);
|
||||
|
||||
// Assert: file exists, sits under the user's root, and
|
||||
// parses as a yavsc.signature/v1 envelope.
|
||||
|
|
@ -99,8 +98,7 @@ public class EstimateSignatureFileHelperTests : IDisposable
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||
user, 1L, SignatureType.Pro, payload: null!,
|
||||
siteSettings, token: ct));
|
||||
user, 1L, SignatureType.Pro, payload: null!, token: ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -111,19 +109,19 @@ public class EstimateSignatureFileHelperTests : IDisposable
|
|||
var ct = TestContext.Current.CancellationToken;
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||
user, 0L, SignatureType.Pro, payload, this.siteSettings, token: ct));
|
||||
user, 0L, SignatureType.Pro, payload, token: ct));
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------
|
||||
|
||||
private static FileReceivedInfo Run(
|
||||
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload, SiteSettings siteSettings)
|
||||
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload)
|
||||
{
|
||||
// The helper is async; tests that don't care about the
|
||||
// result can call it sync via .GetAwaiter().GetResult()
|
||||
// because we know it never throws in the happy path.
|
||||
return EstimateSignatureFileHelper
|
||||
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, siteSettings, CancellationToken.None)
|
||||
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
using DotMarkdown.Linq;
|
||||
using Markdig;
|
||||
using Xunit;
|
||||
using static DotMarkdown.Linq.MFactory;
|
||||
|
||||
|
||||
public class MarkdownTests
|
||||
{
|
||||
[Fact]
|
||||
public void TestMarkdownRendering()
|
||||
{
|
||||
MDocument document = Document(
|
||||
Heading1("Markdown Sample"),
|
||||
Heading2("Bullet List"),
|
||||
BulletList(
|
||||
"text",
|
||||
Bold("bold text")),
|
||||
HorizontalRule(),
|
||||
Heading2("IndentedCodeBlock"),
|
||||
IndentedCodeBlock("string s = null;"));
|
||||
|
||||
Console.WriteLine(document.ToString());
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestAnotherMarkdownRendering()
|
||||
{
|
||||
var result = Markdown.ToHtml("This is a text with some *emphasis*");
|
||||
|
||||
|
||||
Console.WriteLine(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@
|
|||
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
|
|
@ -53,9 +53,9 @@
|
|||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="xunit.runner.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="xunit.runner.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<!--
|
||||
MapStaticAssets() in the production pipeline resolves
|
||||
|
|
@ -89,4 +89,4 @@
|
|||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_YavscOrgStaticAssetsFiles)" DestinationFolder="$(OutDir)" />
|
||||
</Target>
|
||||
</Project>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@
|
|||
"FavIcon": "/favicon.ico",
|
||||
"Icon": "/images/yavsc.png",
|
||||
"GitRepository": "testingrepo",
|
||||
"Blog": "blog",
|
||||
"Bills": "bills",
|
||||
"TempDir": "temp",
|
||||
"Avatars": "avatars",
|
||||
"Owner": {
|
||||
"Name": "Site Owner Name",
|
||||
"EMail": "your@email",
|
||||
|
|
|
|||
|
|
@ -490,17 +490,14 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
|
|||
if (result.Succeeded)
|
||||
{
|
||||
_logger.LogInformation(3, "User created a new account with password.");
|
||||
await _emailSender.SendEmailAsync(
|
||||
_siteSettings.Owner.Name,
|
||||
_siteSettings.Owner.EMail,
|
||||
$"[{_siteSettings.Title}] Inscription avec mot de passe: {user.UserName} ",
|
||||
$"{user.Id}/{user.UserName}/{user.Email}");
|
||||
await _emailSender.SendEmailAsync(Config.SiteSetup.Owner.Name, Config.SiteSetup.Owner.EMail,
|
||||
$"[{_siteSettings.Title}] Inscription avec mot de passe: {user.UserName} ", $"{user.Id}/{user.UserName}/{user.Email}");
|
||||
|
||||
// TODO user.DiskQuota = Startup.SiteSetup.UserFiles.Quota;
|
||||
// For more information on how to enable account confirmation and password reset please visit http://go.microsoft.com/fwlink/?LinkID=532713
|
||||
// Send an email with this link
|
||||
|
||||
Uri authority = new Uri(_siteSettings.Authority);
|
||||
Uri authority = new Uri(Config.Authority);
|
||||
|
||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var callbackUrl = Url.Action("ConfirmEmail", "Account",
|
||||
|
|
@ -706,15 +703,10 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
|
|||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
|
||||
|
||||
await _emailSender.SendEmailAsync(
|
||||
_siteSettings.Owner.Name,
|
||||
_siteSettings.Owner.EMail,
|
||||
$"[{_siteSettings.Title}] Inscription via {info.LoginProvider}: {user.UserName} ",
|
||||
$"{user.Id}/{user.UserName}/{user.Email}");
|
||||
await _emailSender.SendEmailAsync(Config.SiteSetup.Owner.Name, Config.SiteSetup.Owner.EMail,
|
||||
$"[{_siteSettings.Title}] Inscription via {info.LoginProvider}: {user.UserName} ", $"{user.Id}/{user.UserName}/{user.Email}");
|
||||
|
||||
_logger.LogInformation(6,
|
||||
"User created an account using {Name} provider.",
|
||||
info.LoginProvider);
|
||||
_logger.LogInformation(6, "User created an account using {Name} provider.", info.LoginProvider);
|
||||
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,8 @@ namespace Yavsc.Org.Controllers
|
|||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
var uid = User.GetUserId();
|
||||
Models.Blog.BlogPost post = blogSpotService.Create(uid, blogInput);
|
||||
blogSpotService.AttachFiles(Request.Form.Files, uid, post.Id);
|
||||
Models.Blog.BlogPost post = blogSpotService.Create(User.GetUserId(),
|
||||
blogInput, Request.Form.Files);
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
return View("Edit", blogInput);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ namespace Yavsc.Controllers
|
|||
using Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models;
|
||||
using ViewModels.FrontOffice;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
|
@ -21,21 +20,18 @@ namespace Yavsc.Controllers
|
|||
readonly ILogger _logger;
|
||||
readonly IStringLocalizer _SR;
|
||||
private readonly IBillingService _billing;
|
||||
private readonly SiteSettings _siteSettings;
|
||||
|
||||
public FrontOfficeController(ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IBillingService billing,
|
||||
ILoggerFactory loggerFactory,
|
||||
IStringLocalizer<FrontOfficeController> SR,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
IStringLocalizer<FrontOfficeController> SR)
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
||||
_SR = SR;
|
||||
_billing = billing;
|
||||
_siteSettings = siteSettings.Value;
|
||||
}
|
||||
public ActionResult Index()
|
||||
{
|
||||
|
|
@ -99,8 +95,8 @@ namespace Yavsc.Controllers
|
|||
[HttpGet]
|
||||
public IActionResult EstimatePdf(long id)
|
||||
{
|
||||
ViewBag.TempDir = _siteSettings.TempDir;
|
||||
ViewBag.BillsDir = _siteSettings.Bills;
|
||||
ViewBag.TempDir = Config.SiteSetup.TempDir;
|
||||
ViewBag.BillsDir = AbstractFileSystemHelpers.UserBillsDirName;
|
||||
var estimate = _context.Estimates.Include(x => x.Query)
|
||||
.Include(x => x.Query.Client)
|
||||
.Include(x => x.Query.PerformerProfile)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.Org.Controllers
|
||||
namespace Yavsc.Controllers
|
||||
{
|
||||
public class FileSystemController : Controller
|
||||
{
|
||||
private SiteSettings _siteSettings;
|
||||
|
||||
public FileSystemController(IOptions<SiteSettings> siteSettings)
|
||||
public FileSystemController()
|
||||
{
|
||||
_siteSettings = siteSettings.Value;
|
||||
}
|
||||
// Removed redundant empty constructor block
|
||||
|
||||
public IActionResult Index(string subdir="")
|
||||
{
|
||||
{
|
||||
if (subdir !=null)
|
||||
if (!subdir.IsValidYavscPath())
|
||||
return new BadRequestResult();
|
||||
var files = FileSystemHelpers.GetUserFiles(_siteSettings, User.GetUserId(), subdir);
|
||||
var files = AbstractFileSystemHelpers.GetUserFiles(User.GetUserId(), subdir);
|
||||
return View(files);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1044,7 +1044,7 @@ public static class HostingExtensions
|
|||
Startup.Configure(app, siteSettings, smtpSettings,
|
||||
payPalSettings, googleAuthSettings, localization, loggerFactory,
|
||||
app.Environment.EnvironmentName);
|
||||
app.ConfigureFileServerApp(siteSettings.Value);
|
||||
app.ConfigureFileServerApp();
|
||||
app.UseSession();
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1238,44 +1238,24 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
|||
#nullable disable
|
||||
|
||||
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
|
||||
SiteSettings settings,
|
||||
bool enableDirectoryBrowsing = false)
|
||||
{
|
||||
if (settings.GitRepository == null)
|
||||
{
|
||||
throw new InvalidOperationException("GitRepository path is not configured in SiteSettings.");
|
||||
}
|
||||
if (settings.Blog == null)
|
||||
{
|
||||
throw new InvalidOperationException("Blog path is not configured in SiteSettings.");
|
||||
}
|
||||
if (settings.Avatars == null)
|
||||
{
|
||||
throw new InvalidOperationException("Avatars path is not configured in SiteSettings.");
|
||||
}
|
||||
if (settings.Bills == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bills path is not configured in SiteSettings.");
|
||||
}
|
||||
if (settings.TempDir == null)
|
||||
{
|
||||
throw new InvalidOperationException("TempDir path is not configured in SiteSettings.");
|
||||
}
|
||||
|
||||
var userFilesDirInfo = new DirectoryInfo(settings.Blog);
|
||||
var userFilesDirInfo = new DirectoryInfo(Config.SiteSetup.Blog);
|
||||
AbstractFileSystemHelpers.UserFilesDirName = userFilesDirInfo.FullName;
|
||||
|
||||
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
||||
|
||||
Config.UserFilesOptions = new FileServerOptions()
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(userFilesDirInfo.FullName),
|
||||
FileProvider = new PhysicalFileProvider(AbstractFileSystemHelpers.UserFilesDirName),
|
||||
RequestPath = PathString.FromUriComponent(Constants.UserFilesPath),
|
||||
EnableDirectoryBrowsing = enableDirectoryBrowsing,
|
||||
};
|
||||
Config.UserFilesOptions.EnableDefaultFiles = true;
|
||||
Config.UserFilesOptions.StaticFileOptions.ServeUnknownFileTypes = true;
|
||||
|
||||
var avatarsDirInfo = new DirectoryInfo(settings.Avatars);
|
||||
var avatarsDirInfo = new DirectoryInfo(Config.SiteSetup.Avatars);
|
||||
if (!avatarsDirInfo.Exists) avatarsDirInfo.Create();
|
||||
Config.AvatarsDirName = avatarsDirInfo.FullName;
|
||||
|
||||
|
|
@ -1287,7 +1267,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
|
|||
};
|
||||
|
||||
|
||||
var gitdirinfo = new DirectoryInfo(settings.GitRepository);
|
||||
var gitdirinfo = new DirectoryInfo(Config.SiteSetup.GitRepository);
|
||||
Config.GitDirName = gitdirinfo.FullName;
|
||||
if (!gitdirinfo.Exists) gitdirinfo.Create();
|
||||
Config.GitOptions = new FileServerOptions()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
|
||||
using System.Web;
|
||||
using Markdig;
|
||||
using AsciiDocSharp;
|
||||
using AsciiDocSharp.Converters.Html;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
|
||||
namespace Yavsc.Helpers
|
||||
{
|
||||
public class MarkdownTagHelper : TagHelper
|
||||
public class AsciidocTagHelper : TagHelper
|
||||
{
|
||||
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
|
|
@ -17,7 +18,6 @@ namespace Yavsc.Helpers
|
|||
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
if (context.AllAttributes.ContainsName("summary"))
|
||||
|
|
@ -34,10 +34,16 @@ namespace Yavsc.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||
var result = Markdown.ToHtml(text, pipeline);
|
||||
var processor = new AsciiDocProcessor(
|
||||
|
||||
);
|
||||
var htmlConverter = new HtmlDocumentConverter();
|
||||
|
||||
output.Content.AppendHtml(result);
|
||||
var document = processor.ParseFromText(text);
|
||||
var htmlResult = processor.ConvertDocument(document, htmlConverter);
|
||||
|
||||
|
||||
output.Content.AppendHtml(htmlResult);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ namespace Yavsc.Helpers
|
|||
result = result.Replace("\n","\\tabularnewline ");
|
||||
return new TeXString(result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static string NewLinesWith(this string target, string separator)
|
||||
{
|
||||
|
|
@ -115,11 +115,11 @@ namespace Yavsc.Helpers
|
|||
return new TeXString(string.Join(lineSeparator,texlines));
|
||||
}
|
||||
|
||||
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
|
||||
public static bool GenerateEstimatePdf(this PdfGenerationViewModel Model)
|
||||
{
|
||||
string errorMsg = null;
|
||||
var billdir = Model.DestDir;
|
||||
var tempdir = settings.TempDir;
|
||||
var tempdir = Config.SiteSetup.TempDir;
|
||||
string name = Model.BaseFileName;
|
||||
string fullname = new FileInfo(
|
||||
System.IO.Path.Combine(tempdir, name)).FullName;
|
||||
|
|
@ -154,7 +154,7 @@ namespace Yavsc.Helpers
|
|||
{
|
||||
errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}";
|
||||
}
|
||||
else
|
||||
else
|
||||
{
|
||||
fi.Delete();
|
||||
var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d"));
|
||||
|
|
|
|||
316
src/Yavsc.Org/Services/BlogSpotService.cs
Normal file
316
src/Yavsc.Org/Services/BlogSpotService.cs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Access;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Exceptions;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.ViewModels.Auth;
|
||||
|
||||
|
||||
[Obsolete]
|
||||
public class OldBlogSpotService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IFileSystemAuthManager fileSystemAuthManager;
|
||||
|
||||
public OldBlogSpotService(ApplicationDbContext context,
|
||||
IAuthorizationService authorizationService,
|
||||
IFileSystemAuthManager fileSystemAuthManager)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_context = context;
|
||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
}
|
||||
|
||||
public Yavsc.Models.Blog.BlogPost Create(string userId, Yavsc.Models.Blog.BlogPost post, IFormFileCollection files)
|
||||
{
|
||||
// Sauvegarder le post d'abord pour obtenir son ID
|
||||
_context.BlogSpot.Add(post);
|
||||
_context.SaveChanges(userId);
|
||||
|
||||
// Traiter les fichiers attachés s'il y en a
|
||||
if (files != null && files.Count > 0)
|
||||
{
|
||||
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
|
||||
if (user != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Créer un répertoire pour les fichiers du blog
|
||||
string blogFilesSubdir = $"blogs/{post.Id}";
|
||||
string destDir = Path.Combine(
|
||||
AbstractFileSystemHelpers.UserFilesDirName,
|
||||
user.UserName,
|
||||
blogFilesSubdir
|
||||
);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
if (!di.Exists) di.Create();
|
||||
|
||||
// Traiter chaque fichier
|
||||
foreach (var formFile in files)
|
||||
{
|
||||
var fileInfo = user.ReceiveUserFile(destDir, formFile);
|
||||
if (fileInfo != null && !fileInfo.QuotaOffense)
|
||||
{
|
||||
// Créer une entrée UploadedFile si nécessaire
|
||||
var uploadedFile = new UploadedFile
|
||||
{
|
||||
Path = fileInfo.FileName,
|
||||
ContentType = formFile.ContentType,
|
||||
Length = formFile.Length
|
||||
};
|
||||
_context.UploadedFiles.Add(uploadedFile);
|
||||
_context.SaveChanges(userId);
|
||||
|
||||
// Lier le fichier au post
|
||||
var attachment = new BlogAttachedFile
|
||||
{
|
||||
PostId = post.Id,
|
||||
FileId = uploadedFile.Id
|
||||
};
|
||||
_context.BlogAttachedFiles.Add(attachment);
|
||||
}
|
||||
}
|
||||
_context.SaveChanges(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Logger l'erreur mais ne pas échouer la création du post
|
||||
System.Diagnostics.Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
|
||||
{
|
||||
var blog = await _context.BlogSpot.Include(x => x.Author).Include(x => x.ACL).SingleAsync(m => m.Id == blogPostId);
|
||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
||||
if (!auth.Succeeded)
|
||||
{
|
||||
throw new AuthorizationFailureException(auth);
|
||||
}
|
||||
var pub = await _context.blogSpotPublications.AnyAsync(x => x.BlogpostId == blog.Id);
|
||||
ScrubAclForViewer(blog, user);
|
||||
|
||||
return new BlogPostEditViewModel(blog, pub);
|
||||
}
|
||||
|
||||
public async Task<Yavsc.Models.Blog.BlogPost> Details(ClaimsPrincipal user, long blogPostId)
|
||||
{
|
||||
Yavsc.Models.Blog.BlogPost blog = await _context.BlogSpot
|
||||
.Include(p => p.Author)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Comments)
|
||||
.Include(p => p.ACL)
|
||||
.SingleAsync(m => m.Id == blogPostId);
|
||||
if (blog == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new ReadPermission());
|
||||
if (!auth.Succeeded)
|
||||
{
|
||||
throw new AuthorizationFailureException(auth);
|
||||
}
|
||||
ScrubAclForViewer(blog, user);
|
||||
foreach (var c in blog.Comments)
|
||||
{
|
||||
c.Author = _context.Users.First(u => u.Id == c.AuthorId);
|
||||
}
|
||||
return blog;
|
||||
}
|
||||
|
||||
public async Task Modify(ClaimsPrincipal user, BlogPostEditViewModel blogEdit)
|
||||
{
|
||||
var blog = _context.BlogSpot.SingleOrDefault(b => b.Id == blogEdit.Id);
|
||||
Debug.Assert(blog != null);
|
||||
var auth = await _authorizationService.AuthorizeAsync(user, blog, new EditPermission());
|
||||
if (!auth.Succeeded)
|
||||
{
|
||||
throw new AuthorizationFailureException(auth);
|
||||
}
|
||||
blog.Article = blogEdit.Article;
|
||||
blog.Title = blogEdit.Title;
|
||||
blog.Photo = blogEdit.Photo;
|
||||
blog.ACL = blogEdit.ACL;
|
||||
// saves the change
|
||||
_context.Update(blog);
|
||||
var publication = await _context.blogSpotPublications.SingleOrDefaultAsync
|
||||
(p => p.BlogpostId == blogEdit.Id);
|
||||
if (publication != null)
|
||||
{
|
||||
if (!blogEdit.Publish)
|
||||
{
|
||||
_context.blogSpotPublications.Remove(publication);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (blogEdit.Publish)
|
||||
{
|
||||
_context.blogSpotPublications.Add(
|
||||
new BlogSpotPublication
|
||||
{
|
||||
BlogpostId = blogEdit.Id
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task Modify(ClaimsPrincipal user, Yavsc.Models.Blog.BlogPost blog)
|
||||
{
|
||||
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
|
||||
if (existing == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Blog post {blog.Id} not found.");
|
||||
}
|
||||
|
||||
var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission());
|
||||
if (!auth.Succeeded)
|
||||
{
|
||||
throw new AuthorizationFailureException(auth);
|
||||
}
|
||||
|
||||
existing.Title = blog.Title;
|
||||
existing.Article = blog.Article;
|
||||
existing.Photo = blog.Photo;
|
||||
existing.ACL = blog.ACL;
|
||||
|
||||
_context.Update(existing);
|
||||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
|
||||
{
|
||||
string? viewerId = user.Identity?.IsAuthenticated == true ? user.GetUserId() : null;
|
||||
IEnumerable<IBlogPost> posts;
|
||||
|
||||
if (user.Identity.IsAuthenticated)
|
||||
{
|
||||
string viewerIdNonNull = viewerId!;
|
||||
long[] userCircles = await _context.Circle.Include(c => c.Members).
|
||||
Where(c => c.Members.Any(m => m.MemberId == viewerIdNonNull))
|
||||
.Select(c => c.Id).ToArrayAsync();
|
||||
|
||||
posts = _context.BlogSpot
|
||||
.Include(b => b.Author)
|
||||
.Include(p => p.ACL)
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.Comments)
|
||||
.Where(p => p.ACL == null
|
||||
|| p.ACL.Count == 0
|
||||
|| (p.AuthorId == viewerIdNonNull)
|
||||
|| (userCircles != null &&
|
||||
p.ACL.Any(a => userCircles.Contains(a.CircleId)))
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
posts = _context.blogSpotPublications
|
||||
.Include(p => p.BlogPost)
|
||||
.Include(b => b.BlogPost.Author)
|
||||
.Include(p => p.BlogPost.ACL)
|
||||
.Include(p => p.BlogPost.Tags)
|
||||
.Include(p => p.BlogPost.Comments)
|
||||
.Where(p => p.BlogPost.ACL == null
|
||||
|| p.BlogPost.ACL.Count == 0)
|
||||
.Select(p => p.BlogPost).ToArray();
|
||||
}
|
||||
|
||||
var materialised = posts.ToList();
|
||||
foreach (var post in materialised.OfType<Yavsc.Models.Blog.BlogPost>())
|
||||
ScrubAclForViewer(post, user);
|
||||
|
||||
var data = materialised.OrderByDescending(p => p.DateModified)
|
||||
.Skip(skip)
|
||||
.Take(take);
|
||||
return data;
|
||||
}
|
||||
|
||||
public async Task Delete(ClaimsPrincipal user, long id)
|
||||
{
|
||||
var uid = user.GetUserId();
|
||||
Yavsc.Models.Blog.BlogPost blog = _context.BlogSpot.Single(m => m.Id == id);
|
||||
|
||||
_context.BlogSpot.Remove(blog);
|
||||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Yavsc.Models.Blog.BlogPost>> UserPosts(
|
||||
string posterName,
|
||||
string? readerId,
|
||||
int pageLen = 10,
|
||||
int pageNum = 0)
|
||||
{
|
||||
string? posterId = (await _context.Users.SingleOrDefaultAsync(u => u.UserName == posterName))?.Id ?? null;
|
||||
if (posterId == null) return Array.Empty<Yavsc.Models.Blog.BlogPost>();
|
||||
var posts = _context.UserPosts(posterId, readerId).ToList();
|
||||
var viewerId = string.Equals(readerId, posterId, StringComparison.Ordinal) ? readerId : null;
|
||||
foreach (var post in posts)
|
||||
ScrubAclForViewer(post, viewerId);
|
||||
return posts;
|
||||
}
|
||||
|
||||
public object? GetTitle(string title)
|
||||
{
|
||||
return _context.BlogSpot.Include(
|
||||
b => b.Author
|
||||
).Where(x => x.Title == title).OrderByDescending(
|
||||
x => x.DateCreated
|
||||
).ToList();
|
||||
}
|
||||
|
||||
public async Task<Yavsc.Models.Blog.BlogPost?> GetBlogPostAsync(long value)
|
||||
{
|
||||
return await _context.BlogSpot
|
||||
.Include(b => b.Author)
|
||||
.Include(b => b.ACL)
|
||||
.SingleOrDefaultAsync(x => x.Id == value);
|
||||
}
|
||||
|
||||
private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user)
|
||||
{
|
||||
if (!IsOwner(post, user))
|
||||
post.ACL = new List<CircleAuthorizationToBlogPost>();
|
||||
}
|
||||
|
||||
private static void ScrubAclForViewer(Yavsc.Models.Blog.BlogPost post, string? viewerId)
|
||||
{
|
||||
if (!string.Equals(post.AuthorId, viewerId, StringComparison.Ordinal)
|
||||
&& !string.Equals(post.Author?.Id, viewerId, StringComparison.Ordinal))
|
||||
post.ACL = new List<CircleAuthorizationToBlogPost>();
|
||||
}
|
||||
|
||||
private static bool IsOwner(Yavsc.Models.Blog.BlogPost post, ClaimsPrincipal? user)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true) return false;
|
||||
|
||||
var viewerId = user.GetUserId();
|
||||
var viewerName = user.GetUserName() ?? user.Identity?.Name;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(viewerId))
|
||||
{
|
||||
if (string.Equals(post.AuthorId, viewerId, StringComparison.Ordinal)) return true;
|
||||
if (string.Equals(post.Author?.Id, viewerId, StringComparison.Ordinal)) return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(viewerName))
|
||||
{
|
||||
if (string.Equals(post.AuthorId, viewerName, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(post.Author?.UserName, viewerName, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -21,19 +21,21 @@ public class Startup
|
|||
{
|
||||
Config.GoogleSettings = googleSettings.Value;
|
||||
ResourcesHelpers.GlobalLocalizer = localizer;
|
||||
Config.SiteSetup = siteSettings.Value;
|
||||
Config.SmtpSetup = smtpSettings.Value;
|
||||
Config.Authority = siteSettings.Value.Authority;
|
||||
string blogsDir = siteSettings.Value.Blog ?? throw new Exception("blogsDir is not set.");
|
||||
string billsDir = siteSettings.Value.Bills ?? throw new Exception("billsDir is not set.");
|
||||
string tempDir = siteSettings.Value.TempDir ?? throw new Exception("tempDir is not set.");
|
||||
|
||||
AbstractFileSystemHelpers.UserFilesDirName = new DirectoryInfo(blogsDir).FullName;
|
||||
AbstractFileSystemHelpers.UserBillsDirName = new DirectoryInfo(billsDir).FullName;
|
||||
Config.Temp = siteSettings.Value.TempDir;
|
||||
Config.PayPalSettings = payPalSettings.Value;
|
||||
|
||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||
|
||||
// TODO implement an installation & upgrade procedure
|
||||
// Create required directories
|
||||
foreach (string dir in new string[] { blogsDir, billsDir, tempDir })
|
||||
foreach (string dir in new string[] { AbstractFileSystemHelpers.UserFilesDirName, AbstractFileSystemHelpers.UserBillsDirName, Config.Temp })
|
||||
{
|
||||
if (dir == null)
|
||||
{
|
||||
|
|
@ -46,27 +48,27 @@ public class Startup
|
|||
di.Create();
|
||||
}
|
||||
}
|
||||
CheckApp(siteSettings.Value, loggerFactory.CreateLogger<Startup>(), environmentName);
|
||||
CheckApp(loggerFactory.CreateLogger<Startup>(), environmentName);
|
||||
}
|
||||
|
||||
public static void CheckApp(SiteSettings settings, ILogger logger, string environmentName)
|
||||
public static void CheckApp(ILogger logger, string environmentName)
|
||||
{
|
||||
var appData = Environment.GetEnvironmentVariable("APPDATA");
|
||||
if (appData == null)
|
||||
{
|
||||
logger.LogWarning("AppData was not found in environment variables");
|
||||
if (settings.DataDir == null) {
|
||||
settings.DataDir = "AppData"+environmentName;
|
||||
logger.LogInformation("Using: "+settings.DataDir);
|
||||
} else logger.LogInformation("Using value from settings: "+settings.DataDir);
|
||||
DirectoryInfo di = new DirectoryInfo(settings.DataDir);
|
||||
if (Config.SiteSetup.DataDir == null) {
|
||||
Config.SiteSetup.DataDir = "AppData"+environmentName;
|
||||
logger.LogInformation("Using: "+Config.SiteSetup.DataDir);
|
||||
} else logger.LogInformation("Using value from settings: "+Config.SiteSetup.DataDir);
|
||||
DirectoryInfo di = new DirectoryInfo(Config.SiteSetup.DataDir);
|
||||
if (!di.Exists)
|
||||
{
|
||||
di.Create();
|
||||
logger.LogWarning("Created dir : "+di.FullName);
|
||||
}
|
||||
logger.LogInformation("Using existing directory: "+di.FullName);
|
||||
Environment.SetEnvironmentVariable("APPDATA", settings.DataDir);
|
||||
Environment.SetEnvironmentVariable("APPDATA", Config.SiteSetup.DataDir);
|
||||
logger.LogWarning("It has been set to : "+Environment.GetEnvironmentVariable("APPDATA"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ using Yavsc.ViewModels;
|
|||
using Yavsc.ViewModels.Gen;
|
||||
using Yavsc.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.ViewComponents
|
||||
{
|
||||
|
|
@ -17,29 +17,20 @@ namespace Yavsc.ViewComponents
|
|||
readonly ApplicationDbContext dbContext;
|
||||
readonly IBillingService billing;
|
||||
readonly IStringLocalizer<BillViewComponent> localizer;
|
||||
private readonly SiteSettings siteSettings;
|
||||
|
||||
public BillViewComponent(ApplicationDbContext dbContext,
|
||||
public BillViewComponent(ApplicationDbContext dbContext,
|
||||
IStringLocalizer<BillViewComponent> localizer,
|
||||
IBillingService billing,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
IBillingService billing)
|
||||
{
|
||||
this.billing = billing;
|
||||
this.dbContext = dbContext;
|
||||
this.localizer = localizer;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
public async Task<IViewComponentResult> InvokeAsync(
|
||||
string code,
|
||||
IBillable billable,
|
||||
OutputFormat format,
|
||||
bool asBill,
|
||||
SiteSettings settings
|
||||
)
|
||||
public async Task<IViewComponentResult> InvokeAsync(string code, IBillable billable, OutputFormat format, bool asBill)
|
||||
{
|
||||
var di = new DirectoryInfo(settings.Bills);
|
||||
var dia = new DirectoryInfo(settings.Avatars);
|
||||
var di = new DirectoryInfo(Config.SiteSetup.Bills);
|
||||
var dia = new DirectoryInfo(Config.SiteSetup.Avatars);
|
||||
ViewBag.BillsDir = di.FullName;
|
||||
ViewBag.AvatarsDir = dia.FullName;
|
||||
ViewBag.AsBill = asBill; // vrai pour une facture, sinon, c'est un devis
|
||||
|
|
@ -65,9 +56,9 @@ namespace Yavsc.ViewComponents
|
|||
ViewBag.PerformerProfile = profile;
|
||||
ViewBag.ActivityLabel = (await dbContext.Activities.SingleAsync(a => a.Code == billable.ActivityCode)).Name;
|
||||
|
||||
var proAddr = profile.OrganizationAddress.Address;
|
||||
ViewBag.PerformerOrganizationAddress = proAddr.SplitAddressToTeX() ;
|
||||
ViewBag.FooterPerformerOrganizationAddress = proAddr.SplitAddressToTeX(", ");
|
||||
var proaddr = profile.OrganizationAddress.Address;
|
||||
ViewBag.PerformerOrganizationAddress = proaddr.SplitAddressToTeX() ;
|
||||
ViewBag.FooterPerformerOrganizationAddress = proaddr.SplitAddressToTeX(", ");
|
||||
|
||||
ViewBag.PerformerAddress = performer.PostalAddress?.Address.SplitAddressToTeX() ;
|
||||
switch (format) {
|
||||
|
|
@ -84,24 +75,23 @@ namespace Yavsc.ViewComponents
|
|||
tex = writer.ToString();
|
||||
}
|
||||
ViewComponentContext.ViewContext.Writer = oldWriter;
|
||||
|
||||
var pdfGenerationViewModel = new PdfGenerationViewModel
|
||||
{
|
||||
Temp = siteSettings.TempDir,
|
||||
TeXSource = tex,
|
||||
DestDir = siteSettings.Bills,
|
||||
|
||||
var genrtrData = new PdfGenerationViewModel
|
||||
{
|
||||
Temp = Config.Temp,
|
||||
TeXSource = tex,
|
||||
DestDir = AbstractFileSystemHelpers.UserBillsDirName,
|
||||
BaseFileName = billable.GetFileBaseName(billing)
|
||||
};
|
||||
if (settings.GenerateEstimatePdf(pdfGenerationViewModel)) {
|
||||
return this.View(new { Generated = pdfGenerationViewModel.BaseFileName+".pdf" });
|
||||
if (genrtrData.GenerateEstimatePdf()) {
|
||||
return this.View(new { Generated = genrtrData.BaseFileName+".pdf" });
|
||||
} else {
|
||||
return View(new { Error = pdfGenerationViewModel.GenerationErrorMessage } );
|
||||
return View(new { Error = genrtrData.GenerationErrorMessage } );
|
||||
}
|
||||
}
|
||||
ViewBag.BillFileInfo = billable.GetBillInfo(billing, siteSettings);
|
||||
ViewBag.Settings = settings;
|
||||
ViewBag.BillFileInfo = billable.GetBillInfo(billing);
|
||||
return View("Default",billable);
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.ViewModels.UserFiles;
|
||||
|
|
@ -12,24 +11,24 @@ namespace Yavsc.ViewComponents
|
|||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly SiteSettings siteSettings;
|
||||
|
||||
public DirectoryViewComponent(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
IOptions<SiteSettings> siteSettings)
|
||||
public DirectoryViewComponent(UserManager<ApplicationUser> userManager,
|
||||
SignInManager<ApplicationUser> signInManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
public async Task<IViewComponentResult> InvokeAsync(string dirname)
|
||||
{
|
||||
string uid = ViewContext.HttpContext.User.GetUserId();
|
||||
return View(new UserDirectoryInfo(
|
||||
siteSettings.Blog,
|
||||
|
||||
IViewComponentResult result = null;
|
||||
|
||||
result = View(new UserDirectoryInfo(
|
||||
AbstractFileSystemHelpers.UserFilesDirName,
|
||||
uid, dirname));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
|
||||
**Version de Development**
|
||||
|
||||
<markdown>
|
||||
## L'objectif
|
||||
<asciidoc>
|
||||
== L'objectif
|
||||
|
||||
Cette application est construite pour mettre en relation des artistes du domaine musical avec leur public.
|
||||
|
||||
## Le fonctionnement
|
||||
== Le fonctionnement
|
||||
|
||||
Les utilisateurs du site sont soit artiste, soit client, soit administrateur. ils ont tous droit à leur blog.
|
||||
Pour les artistes, c'est un moyen de promouvoir leur activité.
|
||||
|
|
@ -45,7 +45,7 @@ Une fois sa prestation associée exécutée, les paiements relatifs sont effectu
|
|||
|
||||
Pour un contrat exécuté et non honoré par le client, le processus de poursuite en recouvrement est engagé, sinon, le contrat est archivé, des attestations de paiement sont disponibles pour l'artiste et la facture est marquée payée, puis repostée au client.
|
||||
|
||||
## Pour l'artiste
|
||||
=== Pour l'artiste
|
||||
|
||||
L'artiste choisit plusieurs paramètres qui vont faire son profil :
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ L'artiste choisit plusieurs paramètres qui vont faire son profil :
|
|||
* Un agenda Google optionnel, à consulter pour établir sa disponibilité
|
||||
* Des paramètres supplémentaires en fonctions de son type d'activité par exemple, pour les ensembles, leur taille, le cas échéant, leur répertoire ou des indications sur le style de leur musique)
|
||||
|
||||
## Pour le client
|
||||
=== Pour le client
|
||||
|
||||
Il choisit un lieu et une date pour déclarer un événement avenir (il peut bien-sûr en programmer autant qu'il le veut).
|
||||
|
||||
|
|
@ -63,19 +63,11 @@ Il peut, parcourir les publications des artistes, et entamer, sur la base d'un d
|
|||
|
||||
Il a accès à la connaissance des journées connues comme libres des artistes par le système.
|
||||
|
||||
## La confidentialité
|
||||
== La confidentialité
|
||||
|
||||
À l'exception d'adresses postales pour rendez-vous,
|
||||
transmises avec accord explicite,
|
||||
et dans le cadre d'établissement d'un devis,
|
||||
aucune adresse postale,
|
||||
aucune adresse e-mail,
|
||||
ni aucun numéro de téléphone ne sont transmis,
|
||||
ni aux clients, ni aux artistes.
|
||||
Seul le serveur a accès à ces informations.
|
||||
À aucun moment, aucune adresse postale, aucune adresse e-mail ni aucun numéro de téléphone ne sont transmis ni aux clients, ni aux artistes. Seul le système a accès à ces informations.
|
||||
|
||||
De plus, le droit de retrait est permanent et sa mise en oeuvre immédiate.
|
||||
Les artistes comme les clients peuvent demander leur désinscription, qui désactive immédiatement les publications associées à leurs informations,
|
||||
et programme la suppression complète de ces dites informations dans les quinze jours à compter de la demande, sauf demande contradictoire.
|
||||
Les artistes comme les clients peuvent demander leur désinscription, qui désactive immédiatement les publications associées à leurs informations, et programme la suppression complète de ces dites informations dans les quinze jours à compter de la demande, sauf demande contradictoire.
|
||||
L'opération est annulable, jusqu'à deux semaines après sa programmation.
|
||||
</markdown>
|
||||
</asciidoc>
|
||||
|
|
@ -6,6 +6,74 @@
|
|||
bool multipleact = Model.Count()>1;
|
||||
}
|
||||
|
||||
@section scripts {
|
||||
<script>
|
||||
$(document).ready(function(){
|
||||
var car = $('.carousel').first();
|
||||
var fi = car.children('.carousel-inner').first()
|
||||
.children('.item').first();
|
||||
var capt = fi.children('.carousel-caption-s');
|
||||
|
||||
fi.addClass('active');
|
||||
car.animate({'background-color':'rgba(0, 0, 0, .5)'},500,function() {
|
||||
car.animate({'background-color':'rgba(0, 0, 0, 0)'},500)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
}
|
||||
@section subbanner {
|
||||
|
||||
<div id="myCarousel" class="carousel slide" data-ride="carousel" data-interval="6000">
|
||||
@if (multipleact) {
|
||||
|
||||
<ol class="carousel-indicators">
|
||||
@{
|
||||
|
||||
foreach (var act in Model) {
|
||||
if (i==0) {
|
||||
<li data-target="#myCarousel" data-slide-to="0" class="active"></li>
|
||||
} else {
|
||||
<li data-target="#myCarousel" data-slide-to="@i"></li>
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
</ol> }
|
||||
<div class="carousel-inner" role="listbox">
|
||||
@{
|
||||
string cls = "item";
|
||||
i=0;
|
||||
foreach (var act in Model) {
|
||||
<div class="@cls" style="background-image: url('@act.Photo'); background-repeat: no-repeat; ">
|
||||
|
||||
<div class="carousel-caption-s" >
|
||||
<p><em>@act.Name</em><br/>
|
||||
@act.Description </p>
|
||||
|
||||
@if (act.Children.Count>0) {
|
||||
<a asp-route-id="@act.Code" class="btn btn-light">
|
||||
@foreach (Activity c in act.Children) {
|
||||
@Html.DisplayFor(subact=>c)
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
@foreach (var frm in act.Forms) {
|
||||
<a class="btn btn-primary" asp-controller="FrontOffice" asp-action="@frm.ActionName" asp-route-id="@act.Code">
|
||||
@frm.Title
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
i++;
|
||||
cls = "item";
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
@section ctxmenu {
|
||||
@if (ViewBag.Announces!=null) {
|
||||
|
|
@ -13,4 +81,10 @@
|
|||
<img src="@SiteSettings.Value.Logo" alt="announces" title="Annonces @SiteSettings.Value.Title" /></a></li>
|
||||
}
|
||||
}
|
||||
|
||||
@section header {
|
||||
<style>
|
||||
.navbar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
@using Yavsc.Helpers
|
||||
@model Yavsc.ViewModels.Gen.PdfGenerationViewModel
|
||||
|
||||
@{
|
||||
ViewBag.Settings.GenerateEstimatePdf(Model);
|
||||
@{
|
||||
Model.GenerateEstimatePdf();
|
||||
}
|
||||
@if (Model.Generated) {
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
@model Yavsc.ViewModels.Gen.PdfGenerationViewModel
|
||||
|
||||
@{
|
||||
SiteSettings.Value.GenerateEstimatePdf(Model);
|
||||
Model.GenerateEstimatePdf();
|
||||
}
|
||||
@if (Model.Generated) {
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<nav class="navbar navbar-expand-sm navbar-dark bg-dark" aria-label="Yavsc"
|
||||
style="background-image: url('@SiteSettings.Value.Banner');
|
||||
style="background-image: url('@Config.SiteSetup.Banner');
|
||||
background-position: 0%;
|
||||
background-attachment: fixed;">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
|
||||
@SiteSettings.Value.Title
|
||||
@Config.SiteSetup.Title
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar" aria-controls="navbar"
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar" aria-controls="navbar"
|
||||
aria-expanded="true" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -9,18 +9,16 @@
|
|||
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotMarkdown" />
|
||||
<PackageReference Include="HigginsSoft.IdentityServer8" />
|
||||
<PackageReference Include="HigginsSoft.IdentityServer8.AspNetIdentity" />
|
||||
<PackageReference Include="HigginsSoft.IdentityServer8.EntityFramework" />
|
||||
<PackageReference Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" />
|
||||
<PackageReference Include="HigginsSoft.IdentityServer8.Security" />
|
||||
<PackageReference Include="HigginsSoft.IdentityServer8.Storage" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" />
|
||||
|
|
|
|||
|
|
@ -18,11 +18,8 @@
|
|||
"Authority": "https://[Your domaine name]",
|
||||
"ExternalUrl": "https://[Your domaine name]",
|
||||
"CorsAllowedOrigins": [
|
||||
"https://your.api.url.com/api/v1",
|
||||
"https://localhost:5003/api/v1",
|
||||
"https://localhost:5005/api/v1"
|
||||
"*"
|
||||
],
|
||||
"Audience": [ "api", "blogs"],
|
||||
"Banner": "/images/banner-dewang.jpg",
|
||||
"Owner": {
|
||||
"Name": "[Site owner's name]",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
"Audience": ["blogs", "api"],
|
||||
"ExternalUrl": "https://[Your domaine name]",
|
||||
"ApiUrl": "https://[Your API domaine name]",
|
||||
"CorsAllowedOrigins": [
|
||||
"*"
|
||||
],
|
||||
"Banner": "/images/banner-dewang.jpg",
|
||||
"Owner": {
|
||||
"Name": "[Site owner's name]",
|
||||
|
|
@ -33,8 +36,7 @@
|
|||
"Bills": "bills",
|
||||
"Blog": "blog",
|
||||
"TempDir": "temp",
|
||||
"DataDir": "data",
|
||||
"GitRepository": "git"
|
||||
"DataDir": "data"
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "[YOURSMTPHOST]",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#nullable enable annotations
|
||||
|
||||
|
||||
|
||||
using IdentityServer8;
|
||||
using IdentityServer8.Models;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
|
@ -14,42 +14,44 @@ public static class Config
|
|||
/// <summary>
|
||||
/// Authority URL for IdentityServer, used for authentication and authorization.
|
||||
/// </summary>
|
||||
public static string Authority { get; set; }
|
||||
public static string Authority { get; set; }
|
||||
|
||||
public static string AuthorityDomain
|
||||
public static string AuthorityDomain
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Uri.TryCreate(Authority, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.GetLeftPart(UriPartial.Authority);
|
||||
if (Uri.TryCreate(Authority, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.GetLeftPart(UriPartial.Authority);
|
||||
}
|
||||
throw new InvalidOperationException("Invalid Authority URL");
|
||||
}
|
||||
throw new InvalidOperationException("Invalid Authority URL");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static IConfigurationRoot? GoogleWebClientConfiguration { get; set; }
|
||||
public static GoogleServiceAccount? GServiceAccount { get; set; }
|
||||
|
||||
public static IConfigurationRoot? GoogleWebClientConfiguration { get; set; }
|
||||
public static GoogleServiceAccount? GServiceAccount { get; set; }
|
||||
public static SiteSettings SiteSetup { get; set; } = new SiteSettings();
|
||||
public static FileServerOptions? UserFilesOptions { get; set; }
|
||||
public static FileServerOptions? GitOptions { get; set; }
|
||||
public static string AvatarsDirName { set; get; } = "Avatars";
|
||||
public static string GitDirName { set; get; } = "Git";
|
||||
|
||||
public static FileServerOptions? UserFilesOptions { get; set; }
|
||||
public static FileServerOptions? GitOptions { get; set; }
|
||||
public static string AvatarsDirName { set; get; } = "Avatars";
|
||||
public static string GitDirName { set; get; } = "Git";
|
||||
|
||||
public static GoogleAuthSettings? GoogleSettings { get; set; }
|
||||
public static SmtpSettings? SmtpSetup { get; set; }
|
||||
public static FileServerOptions? AvatarsOptions { get; set; }
|
||||
public static string UserBillsDirName { set; get; } = "Bills";
|
||||
public static string UserFilesDirName { set; get; } = "Files";
|
||||
public static GoogleAuthSettings? GoogleSettings { get; set; }
|
||||
public static SmtpSettings? SmtpSetup { get; set; }
|
||||
public static string? Temp { get; set; }
|
||||
public static FileServerOptions? AvatarsOptions { get; set; }
|
||||
public static string UserBillsDirName { set; get; } = "Bills";
|
||||
public static string UserFilesDirName { set; get; } = "Files";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lists Available user profile classes,
|
||||
/// populated at startup, using reflection.
|
||||
/// </summary>
|
||||
public static List<Type> ProfileTypes = new List<Type>();
|
||||
/// <summary>
|
||||
/// Lists Available user profile classes,
|
||||
/// populated at startup, using reflection.
|
||||
/// </summary>
|
||||
public static List<Type> ProfileTypes = new List<Type>();
|
||||
|
||||
|
||||
public static IEnumerable<IdentityResource> IdentityResources =>
|
||||
|
|
|
|||
|
|
@ -21,12 +21,11 @@ namespace Yavsc.Helpers
|
|||
return bill;
|
||||
}
|
||||
|
||||
public static FileInfo GetBillInfo(this IBillable bill,
|
||||
IBillingService service, SiteSettings siteSettings)
|
||||
public static FileInfo GetBillInfo(this IBillable bill, IBillingService service)
|
||||
{
|
||||
var suffix = bill.GetIsAcquitted() ? "-ack":null;
|
||||
var filename = bill.GetFileBaseName(service)+".pdf";
|
||||
return new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
||||
return new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ public static class EstimateSignatureFileHelper
|
|||
long estimateId,
|
||||
SignatureType type,
|
||||
SignaturePadPayload payload,
|
||||
SiteSettings siteSettings,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (user is null) throw new ArgumentNullException(nameof(user));
|
||||
|
|
@ -78,7 +77,7 @@ public static class EstimateSignatureFileHelper
|
|||
// (not user-derived), so we skip the IsValidYavscPath
|
||||
// check that ReceiveUserFile performs on user-supplied
|
||||
// subpaths.
|
||||
var root = user.EnsureDestinationDirectory(SignaturesSubdir, siteSettings);
|
||||
var root = user.EnsureDestinationDirectory(SignaturesSubdir);
|
||||
|
||||
var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
|
||||
var fullPath = Path.Combine(root, fileName);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ using Yavsc.Exceptions;
|
|||
using Yavsc.Abstract.Helpers;
|
||||
using ImageMagick;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
using Yavsc.ViewModels.UserFiles;
|
||||
namespace Yavsc.Server.Helpers
|
||||
{
|
||||
public static class FileSystemHelpers
|
||||
|
|
@ -28,12 +27,10 @@ namespace Yavsc.Server.Helpers
|
|||
}
|
||||
}
|
||||
}
|
||||
public static async Task<FileReceivedInfo> ReceiveProSignatureAsync(this ClaimsPrincipal user, string billingCode, long estimateId, IFormFile formFile, string signType,
|
||||
SiteSettings settings
|
||||
)
|
||||
public static async Task<FileReceivedInfo> ReceiveProSignatureAsync(this ClaimsPrincipal user, string billingCode, long estimateId, IFormFile formFile, string signType)
|
||||
{
|
||||
var item = new FileReceivedInfo(
|
||||
settings.Bills,
|
||||
Config.SiteSetup.Bills,
|
||||
AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, estimateId));
|
||||
|
||||
var fi = new FileInfo(item.FullName);
|
||||
|
|
@ -42,11 +39,16 @@ namespace Yavsc.Server.Helpers
|
|||
return item;
|
||||
}
|
||||
|
||||
public static string GetAvatarUri(this ApplicationUser user)
|
||||
{
|
||||
return $"/{Config.SiteSetup.Avatars}/{user.UserName}.png";
|
||||
}
|
||||
|
||||
public static string EnsureDestinationDirectory(
|
||||
this ClaimsPrincipal user,
|
||||
string subpath, SiteSettings siteSettings)
|
||||
string subpath)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.Identity.Name);
|
||||
var root = Path.Combine(AbstractFileSystemHelpers.UserFilesDirName, user.Identity.Name);
|
||||
if (!string.IsNullOrWhiteSpace(subpath))
|
||||
{
|
||||
if (!subpath.IsValidYavscPath())
|
||||
|
|
@ -67,18 +69,18 @@ namespace Yavsc.Server.Helpers
|
|||
/// <param name="user"></param>
|
||||
/// <param name="fileName"></param>
|
||||
|
||||
public static void DeleteUserFile(this ApplicationUser user, string fileName, SiteSettings siteSettings)
|
||||
public static void DeleteUserFile(this ApplicationUser user, string fileName)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(AbstractFileSystemHelpers.UserFilesDirName, user.UserName);
|
||||
var fi = new FileInfo(Path.Combine(root, fileName));
|
||||
if (!fi.Exists) return;
|
||||
fi.Delete();
|
||||
user.DiskUsage -= fi.Length;
|
||||
}
|
||||
|
||||
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
|
||||
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(AbstractFileSystemHelpers.UserFilesDirName, user.UserName);
|
||||
if (string.IsNullOrEmpty(dirName))
|
||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a directory or file name" };
|
||||
|
||||
|
|
@ -99,9 +101,9 @@ namespace Yavsc.Server.Helpers
|
|||
return new FsOperationInfo { Done = true };
|
||||
}
|
||||
|
||||
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName, SiteSettings siteSettings)
|
||||
public static FsOperationInfo MoveUserDir(this ApplicationUser user, string fromDirName, string toDirName)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(AbstractFileSystemHelpers.UserFilesDirName, user.UserName);
|
||||
if (string.IsNullOrEmpty(fromDirName))
|
||||
return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.InvalidRequest, ErrorMessage = "specify a dir name " };
|
||||
|
||||
|
|
@ -125,49 +127,48 @@ namespace Yavsc.Server.Helpers
|
|||
di.MoveTo(destPath);
|
||||
return new FsOperationInfo { Done = true };
|
||||
}
|
||||
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
||||
public static FsOperationInfo MoveUserFileToDir(this ApplicationUser user, string fileNameFrom, string fileNameDest)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(AbstractFileSystemHelpers.UserFilesDirName, user.UserName);
|
||||
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||
if (!fi.Exists) return new FsOperationInfo { ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||
string dest;
|
||||
if (!string.IsNullOrEmpty(fileNameDest)) dest = Path.Combine(root, fileNameDest);
|
||||
else dest = root;
|
||||
var fo = new FileInfo(dest);
|
||||
if (fo.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
|
||||
if (fo.Exists) return new FsOperationInfo { ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
|
||||
var dout = new DirectoryInfo(dest);
|
||||
if (!dout.Exists) dout.Create();
|
||||
fi.MoveTo(Path.Combine(dout.FullName, fi.Name));
|
||||
return new FsOperationInfo { Done = true };
|
||||
}
|
||||
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest, SiteSettings siteSettings)
|
||||
public static FsOperationInfo MoveUserFile(this ApplicationUser user, string fileNameFrom, string fileNameDest)
|
||||
{
|
||||
var root = Path.Combine(siteSettings.Blog, user.UserName);
|
||||
var root = Path.Combine(AbstractFileSystemHelpers.UserFilesDirName, user.UserName);
|
||||
var fi = new FileInfo(Path.Combine(root, fileNameFrom));
|
||||
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||
if (!fi.Exists) return new FsOperationInfo { ErrorCode = ErrorCode.NotFound, ErrorMessage = "no file to move" };
|
||||
var fo = new FileInfo(Path.Combine(root, fileNameDest));
|
||||
if (fo.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
|
||||
if (fo.Exists) return new FsOperationInfo { ErrorCode = ErrorCode.DestExists, ErrorMessage = "destination file name is an existing file" };
|
||||
fi.MoveTo(fo.FullName);
|
||||
return new FsOperationInfo { Done = true };
|
||||
}
|
||||
|
||||
static string ParseFileNameFromDisposition(string disposition)
|
||||
{
|
||||
// form-data_ name=_file__ filename=_Constants.Private.cs_
|
||||
var parts = disposition.Split(' ');
|
||||
var filename = parts[2].Split('=')[1];
|
||||
filename = filename.Substring(1, filename.Length - 2);
|
||||
return filename;
|
||||
}
|
||||
|
||||
public static void AddQuota(this ApplicationUser user, int quota)
|
||||
{
|
||||
user.DiskQuota += quota;
|
||||
}
|
||||
public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, IFormFile f, string destFileName = null)
|
||||
{
|
||||
// f.FileName is parsed by ASP.NET Core from the
|
||||
// Content-Disposition header: it handles unquoted
|
||||
// values and the RFC 5987 filename* parameter that
|
||||
// modern HttpClient multipart bodies emit. The
|
||||
// previous hand-rolled parser assumed a quoted
|
||||
// filename in a fixed position and silently dropped
|
||||
// the first character ("note.txt" → "ote.txt"),
|
||||
// which broke the attachment links PostIt appends
|
||||
// to the article. FilterFileName, downstream, still
|
||||
// sanitises any path traversal attempt.
|
||||
return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? f.FileName, f.ContentType, CancellationToken.None);
|
||||
return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? ParseFileNameFromDisposition(f.ContentDisposition), f.ContentType, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -243,9 +244,7 @@ namespace Yavsc.Server.Helpers
|
|||
|
||||
}
|
||||
|
||||
public static FileReceivedInfo ReceiveAvatar(this ApplicationUser user,
|
||||
IFormFile formFile,
|
||||
SiteSettings settings)
|
||||
public static FileReceivedInfo ReceiveAvatar(this ApplicationUser user, IFormFile formFile)
|
||||
{
|
||||
if (user == null) throw new ArgumentNullException(nameof(user));
|
||||
if (formFile == null) throw new ArgumentNullException(nameof(formFile));
|
||||
|
|
@ -256,7 +255,7 @@ namespace Yavsc.Server.Helpers
|
|||
avatarsRequestPath = Constants.AvatarsPath;
|
||||
}
|
||||
|
||||
var avatarsDirectory = settings.Avatars;
|
||||
var avatarsDirectory = Config.SiteSetup?.Avatars;
|
||||
if (string.IsNullOrWhiteSpace(avatarsDirectory))
|
||||
{
|
||||
avatarsDirectory = "avatars";
|
||||
|
|
@ -306,12 +305,5 @@ namespace Yavsc.Server.Helpers
|
|||
return $"{basename}-{flow.SequenceNumber}{ext}";
|
||||
}
|
||||
|
||||
|
||||
public static UserDirectoryInfo GetUserFiles(SiteSettings settings, string userId, string subdir)
|
||||
{
|
||||
UserDirectoryInfo di = new UserDirectoryInfo(settings.Blog, userId, subdir);
|
||||
return di;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,22 @@ public static class ServiceExtensions
|
|||
};
|
||||
}
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
// Fallback for clients that cannot reliably attach Authorization header
|
||||
// on multipart uploads. Restrict query-token support to this endpoint only.
|
||||
if (string.IsNullOrEmpty(context.Token)
|
||||
&& context.Request.Path.Value?.Contains("/api/v1/account/set-avatar", StringComparison.OrdinalIgnoreCase) == true
|
||||
&& context.Request.Query.TryGetValue("access_token", out var tokenValues))
|
||||
{
|
||||
context.Token = tokenValues.ToString();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ using System.Security.Claims;
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc;
|
||||
using Yavsc.Blogspot;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Access;
|
||||
|
|
@ -22,96 +19,72 @@ public class BlogSpotService
|
|||
private readonly ApplicationDbContext _context;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IFileSystemAuthManager fileSystemAuthManager;
|
||||
private readonly SiteSettings siteSettings;
|
||||
private readonly ILogger<BlogSpotService> logger;
|
||||
|
||||
public BlogSpotService(
|
||||
ApplicationDbContext context,
|
||||
IAuthorizationService authorizationService,
|
||||
IFileSystemAuthManager fileSystemAuthManager,
|
||||
IOptions<SiteSettings> siteSettings,
|
||||
ILoggerFactory loggerFactory)
|
||||
IFileSystemAuthManager fileSystemAuthManager)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_context = context;
|
||||
this.fileSystemAuthManager = fileSystemAuthManager;
|
||||
this.siteSettings = siteSettings.Value;
|
||||
if (siteSettings.Value.Blog == null)
|
||||
{
|
||||
throw new InvalidOperationException("SiteSettings.Blog is not configured.");
|
||||
}
|
||||
this.logger = loggerFactory.CreateLogger<BlogSpotService>();
|
||||
}
|
||||
|
||||
public void AttachFiles(IFormFileCollection files, string userId, long postId)
|
||||
{
|
||||
// Traiter les fichiers attaches s'il y en a
|
||||
if (files != null && files.Count > 0)
|
||||
{
|
||||
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
|
||||
if (user == null)
|
||||
throw new InvalidOperationException($"Utilisateur {userId} non trouvé.");
|
||||
|
||||
try
|
||||
{
|
||||
string blogFilesSubdir = $"blogs/{postId}";
|
||||
string destDir = Path.Combine(
|
||||
siteSettings.Blog,
|
||||
user.UserName,
|
||||
blogFilesSubdir);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
|
||||
if (!di.Exists)
|
||||
{
|
||||
logger.LogInformation("Creating directory for blog attachments: {destDir}", destDir);
|
||||
di.Create();
|
||||
}
|
||||
|
||||
foreach (var formFile in files)
|
||||
{
|
||||
var fileInfo = user.ReceiveUserFile(destDir, formFile);
|
||||
if (fileInfo != null && !fileInfo.QuotaOffense)
|
||||
{
|
||||
logger.LogInformation("Attached file {fileName} to blog post {postId} for user {userId}.", fileInfo.FileName, postId, userId);
|
||||
var uploadedFile = new UploadedFile
|
||||
{
|
||||
Path = fileInfo.FileName,
|
||||
ContentType = formFile.ContentType,
|
||||
Length = formFile.Length
|
||||
};
|
||||
_context.UploadedFiles.Add(uploadedFile);
|
||||
_context.SaveChanges(userId);
|
||||
|
||||
var attachment = new BlogAttachedFile
|
||||
{
|
||||
PostId = postId,
|
||||
FileId = uploadedFile.Id
|
||||
};
|
||||
_context.BlogAttachedFiles.Add(attachment);
|
||||
logger.LogInformation("Created BlogAttachedFile entry for file {fileName} and blog post {postId}.", fileInfo.FileName, postId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Failed to attach file {fileName} to blog post {postId} for user {userId}. Quota offense: {quotaOffense}", formFile.FileName, postId, userId, fileInfo?.QuotaOffense);
|
||||
}
|
||||
}
|
||||
_context.SaveChanges(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
||||
logger.LogError(ex, "Error while processing attached files for blog post {postId} and user {userId}.", postId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BlogPost Create(string userId, BlogPost post )
|
||||
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
|
||||
{
|
||||
// Sauvegarder le post d'abord pour obtenir son ID
|
||||
// Le createur vient de l'authentification, donc on ne le prend pas du post
|
||||
post.AuthorId = userId;
|
||||
_context.BlogSpot.Add(post);
|
||||
_context.SaveChanges(userId);
|
||||
|
||||
// Traiter les fichiers attaches s'il y en a
|
||||
if (files != null && files.Count > 0)
|
||||
{
|
||||
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
|
||||
if (user != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string blogFilesSubdir = $"blogs/{post.Id}";
|
||||
string destDir = Path.Combine(
|
||||
AbstractFileSystemHelpers.UserFilesDirName,
|
||||
user.UserName,
|
||||
blogFilesSubdir);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
if (!di.Exists) di.Create();
|
||||
|
||||
foreach (var formFile in files)
|
||||
{
|
||||
var fileInfo = user.ReceiveUserFile(destDir, formFile);
|
||||
if (fileInfo != null && !fileInfo.QuotaOffense)
|
||||
{
|
||||
var uploadedFile = new UploadedFile
|
||||
{
|
||||
Path = fileInfo.FileName,
|
||||
ContentType = formFile.ContentType,
|
||||
Length = formFile.Length
|
||||
};
|
||||
_context.UploadedFiles.Add(uploadedFile);
|
||||
_context.SaveChanges(userId);
|
||||
|
||||
var attachment = new BlogAttachedFile
|
||||
{
|
||||
PostId = post.Id,
|
||||
FileId = uploadedFile.Id
|
||||
};
|
||||
_context.BlogAttachedFiles.Add(attachment);
|
||||
}
|
||||
}
|
||||
_context.SaveChanges(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +186,58 @@ public class BlogSpotService
|
|||
_context.SaveChanges(user.GetUserId());
|
||||
}
|
||||
|
||||
public async Task Modify(ClaimsPrincipal user, BlogPost blog, IFormFileCollection files)
|
||||
{
|
||||
await Modify(user, blog);
|
||||
|
||||
if (files == null || files.Count == 0)
|
||||
return;
|
||||
|
||||
var userId = user.GetUserId();
|
||||
var userEntity = _context.Users.FirstOrDefault(u => u.Id == userId);
|
||||
if (userEntity == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
string blogFilesSubdir = $"blogs/{blog.Id}";
|
||||
string destDir = Path.Combine(
|
||||
AbstractFileSystemHelpers.UserFilesDirName,
|
||||
userEntity.UserName,
|
||||
blogFilesSubdir);
|
||||
var di = new DirectoryInfo(destDir);
|
||||
if (!di.Exists) di.Create();
|
||||
|
||||
foreach (var formFile in files)
|
||||
{
|
||||
var fileInfo = userEntity.ReceiveUserFile(destDir, formFile);
|
||||
if (fileInfo != null && !fileInfo.QuotaOffense)
|
||||
{
|
||||
var uploadedFile = new UploadedFile
|
||||
{
|
||||
Path = fileInfo.FileName,
|
||||
ContentType = formFile.ContentType,
|
||||
Length = formFile.Length
|
||||
};
|
||||
_context.UploadedFiles.Add(uploadedFile);
|
||||
_context.SaveChanges(userId);
|
||||
|
||||
var attachment = new BlogAttachedFile
|
||||
{
|
||||
PostId = blog.Id,
|
||||
FileId = uploadedFile.Id
|
||||
};
|
||||
_context.BlogAttachedFiles.Add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
_context.SaveChanges(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,72 +6,89 @@ namespace Yavsc
|
|||
{
|
||||
public class SiteSettings
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Title { get; set; } = "Yavsc";
|
||||
|
||||
public string Slogan { get; set; }
|
||||
public string Banner { get; set; }
|
||||
public string Slogan { get; set; } = "";
|
||||
public string Banner { get; set; } = "";
|
||||
|
||||
public string StyleSheet { get; set; }
|
||||
public string FavIcon { get; set; }
|
||||
public string Logo { get; set; }
|
||||
public string StyleSheet { get; set; } = "site.css";
|
||||
public string FavIcon { get; set; } = "favicon.ico";
|
||||
public string Logo { get; set; } = "logo.png";
|
||||
/// <summary>
|
||||
/// Origins to allow via the CORS "default" policy.
|
||||
/// Each entry must be a full origin (scheme + host [+ port]), e.g. "https://app.example.com".
|
||||
/// </summary>
|
||||
public IList<string> CorsAllowedOrigins { get; set; }
|
||||
public IList<string> CorsAllowedOrigins { get; set; } = new List<string>
|
||||
{
|
||||
"https://localhost:5001"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// External Url
|
||||
/// </summary>
|
||||
/// <value></value>
|
||||
public string ExternalUrl { get; set; }
|
||||
public string ExternalUrl { get; set; } = "http://lua.pschneider.fr";
|
||||
/// <summary>
|
||||
/// Base URL of the API fronting this site.
|
||||
/// </summary>
|
||||
public string ApiUrl { get; set; }
|
||||
public string ApiUrl { get; set; } = "";
|
||||
/// <summary>
|
||||
/// Must be a fqdn.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public string Authority { get; set; }
|
||||
public string Authority { get; set; } = "lua.pschneider.fr";
|
||||
/// <summary>
|
||||
/// Owner's email
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public StaticContact Owner { get; set; }
|
||||
public StaticContact Owner { get; set; } = new StaticContact
|
||||
{
|
||||
EMail = "root@lua.pschneider.fr",
|
||||
Name = "Root"
|
||||
} ;
|
||||
|
||||
/// <summary>
|
||||
/// Administrator's email
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public StaticContact Admin { get; set; }
|
||||
public StaticContact Admin { get; set; } = new StaticContact
|
||||
{
|
||||
EMail = "root@lua.pschneider.fr",
|
||||
Name = "Root"
|
||||
} ;
|
||||
|
||||
public string DataDir { get; set; }
|
||||
public string Avatars { get; set; }
|
||||
public string DataDir { get; set; } = "data";
|
||||
public string Avatars { get; set; } = "avatars";
|
||||
public long Quota { get; set; }
|
||||
public string Blog { get; set; }
|
||||
public string Bills { get; set; }
|
||||
public string GitRepository { get; set; }
|
||||
public string Blog { get; set; } = "blogs";
|
||||
public string Bills { get; set; } = "bills";
|
||||
public string GitRepository { get; set; } = "sources";
|
||||
|
||||
public string BusinessName { get; set; } = "Yavsc";
|
||||
public string? Street { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? CountryCode { get; set; }
|
||||
|
||||
public string HomeViewName { get; set; } = "Home";
|
||||
/// <summary>
|
||||
/// Specifies the directory where should be
|
||||
/// generated pdf files using pandoc
|
||||
/// </summary>
|
||||
/// <returns>The temporary directory to use</returns>
|
||||
public string TempDir { get; set; }
|
||||
public string TempDir { get; set; } = "temp";
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage user list maximum length in memory
|
||||
/// </summary>
|
||||
/// <value></value>
|
||||
public int DUUserListLen { get; set; }
|
||||
public int DUUserListLen { get; set; } = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Default acl file name
|
||||
/// </summary>
|
||||
/// <value></value>
|
||||
public string AccessListFileName { get; set; }
|
||||
public string AccessListFileName { get; set; } = ".access";
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
<Library>true</Library>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Anthropic.SDK" />
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
-->
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.1.0.0</FileVersion>
|
||||
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
|
||||
<Version>1.1.0-beta.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Razor" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue