Compare commits

..
103 changed files with 159 additions and 864 deletions

View file

@ -50,20 +50,9 @@ jobs:
echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal
- name: Test
artifacts:
name: test-results
path: test-results.html
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \
--report-xunit \
--report-xunit-html \
--report-xunit-html-filename "test-results.html"
TEST_EXIT_CODE=$?
echo "Test exit code: $TEST_EXIT_CODE"
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE
else
echo "✅ All tests passed successfully."
fi

5
.gitignore vendored
View file

@ -45,6 +45,7 @@ DataDir/
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
/test-report.html
/test-report.xml
# Exclude test reports (local test report, not for the repo)
/test-reports

8
.vscode/launch.json vendored
View file

@ -22,9 +22,11 @@
},
{
"name": "Android Debug",
"type": "maui",
"request": "launch",
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj"
"type": "mono",
"preLaunchTask": "run-debug-android",
"request": "attach",
"address": "localhost",
"port": 55555
},
{
"name": "Android Attach - Debug",

View file

@ -45,6 +45,5 @@
]
}
},
"dotnet.defaultSolution": "yavsc.sln",
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln"
"dotnet.defaultSolution": "yavsc.sln"
}

6
.vscode/tasks.json vendored
View file

@ -4,13 +4,9 @@
"tasks": [
{
"label": "build-debug-android",
"label": "run-debug-android",
"command": "dotnet",
"detail": "Build the PostIt Android project in debug mode",
"type": "shell",
"runOptions": {
},
"problemMatcher": ["$msCompile"],
"options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",

View file

@ -1,101 +1,5 @@
# Changelog
## [1.0.8-rc18] - unstable
Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
(arbre de stockage indexé par le login, pas par le `sub`), boutons
d'action des listes réparés (binding `$parent`), revendication `name`
enfin présente dans les access tokens (branchement du `ProfileService`),
et négociation HTTP/3 côté client PostIt.
### Added
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
desktop sans libmsquic).
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
(`CanExecute`) et déclenche l'appel API au clic.
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
mint un vrai JWT via le token endpoint IdentityServer (password grant,
`openid profile`) et asserte que l'access token porte `name`=login et
que `GetUserName()` résout vers ce login.
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
découple `sub` et login, téléverse sous le login, et asserte que
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
le `sub` n'existe pas).
### Changed
* [Yavsc.Org] Branchement du `ProfileService` custom
(`AddProfileService<ProfileService>()` après
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
IdentityServer8 retombait sur son profile service par défaut, qui
n'émet que les claims listés dans les `UserClaims` des API resources.
`name` n'en faisait jamais partie, donc tout access token était sans
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
`User.Identity.Name` injoignable. Le service émet désormais
`name`=`user.UserName`, `email` et les rôles pour tout client dont
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
courts de claims tels qu'émis).
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
la racine de stockage par utilisateur.
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
de test ; réensemencement des identity resources openid/profile dans
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
in-memory partagé et les laissait absentes → `invalid_scope`).
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
(défaut = subject) pour refléter la forme production où
`sub` (GUID) ≠ `name` (login).
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
sérialiser avec le build d'hôte qui mute les registres statiques
`BillingService`/`Config` (race parallèle pré-existante).
### Fixed
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
stockage perso est indexé par le login partout ailleurs
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
le endpoint de téléchargement et les avatars utilisent
`user.UserName`). Une fois l'upload corrigé, les fichiers
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
l'utilisateur en base et liste sous `user.UserName`.
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
interpole toujours par `uid`.
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
pas un contrôle — donc résolvait à `null` et le `Button` restait
désactivé (grisé) et inerte au clic. Corrigé en
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
pas sélectionnables/copiables : le texte était un `TextBlock` (non
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
renvoyait un 40x alors que le fichier était bien supprimé du disque.
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
du contrôleur transformait cette exception en 400, d'où le message
perçu « le fichier n'existe pas sur le serveur » tandis que la
suppression avait réussi. La taille est désormais capturée avant la
suppression.
* PostIt default settings
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
@ -605,8 +509,6 @@ the ACL now comes along with the article,
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7

View file

@ -21,30 +21,7 @@ src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
test:
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "test-results.html"
test-postit:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "postit-test-results.html" \
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
Api-backend-test:
Blogs-backend-test:
Org-backend-test:
%-backend-test:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "$*-backend-test-results.html" \
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
ASPNETCORE_ENVIRONMENT=Development dotnet test
watch:
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj

View file

@ -27,14 +27,4 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
this file's item groups, so the removal must run in a target. -->
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
<ItemGroup>
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
</ItemGroup>
</Target>
</Project>
</Project>

View file

@ -1,208 +0,0 @@
using System.Net.Http;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless UI coverage for the "Mes fichiers" page
/// (<see cref="MyFilesPage"/>). The user reported that once a file
/// is listed, "l'item est inerte au click, et ses boutons restent
/// grisés, désactivés" — neither the row nor its action buttons
/// (Télécharger / Supprimer) respond to a click.
///
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
/// the Avalonia headless platform with a stub
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
/// realised visual tree to find the row's action buttons and
/// assert the two things that would silently break and reproduce
/// the "grayed / inert" symptom:</para>
/// <list type="number">
/// <item><description>The <c>Command</c> binding
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
/// resolved to a non-null command — a null command leaves the
/// button disabled (grayed) and click-silent.</description></item>
/// <item><description>The command is <b>armed</b>
/// (<c>CanExecute</c> true) for the listed file — an
/// <c>AsyncRelayCommand</c> with
/// <c>AllowConcurrentExecutions=false</c> (the
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
/// default) disables itself while running; a button stuck
/// "running" is exactly the grayed-and-inert report.</description></item>
/// <item><description>Executing the command actually invokes the
/// file download / delete on the API client — the row
/// "activates" on click instead of being inert.</description></item>
/// </list>
/// <para>The OS save picker is absent under headless, so
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
/// download command's <c>catch</c> sets an error status — but the
/// API download is awaited <em>before</em> the picker, so the call
/// is recorded regardless. We do not <c>await</c> the command's
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
/// (platform-dependent) picker.</para>
/// </summary>
public class MyFilesPageHeadlessTests
{
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
{
var api = new StubFsApi();
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
var vm = new MyFilesViewModel(fsClient);
var page = new MyFilesPage { DataContext = vm };
return (page, vm, api);
}
/// <summary>
/// Mount the page the way the app does — pushed onto a
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
/// (a <see cref="ContentPage"/> realizes its content template via
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
/// listing a single file by awaiting
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
/// <see cref="MyFilesViewModel.Files"/> before the first layout
/// pass lets the <c>ItemsControl</c> realise its item templates
/// under headless.
/// </summary>
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
{
var (page, vm, api) = CreatePage();
await vm.InitializeAsync();
var nav = new NavigationPage();
nav.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Content = nav };
window.Show();
// Let bindings / the ItemsControl item templates apply.
await Task.Delay(50);
return (page, vm, api);
}
/// <summary>
/// Find the first action button in a file row whose content
/// matches <paramref name="content"/>. The file rows live in an
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
/// walk the visual tree.
/// </summary>
private static Button? FindRowButton(MyFilesPage page, string content)
=> page.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content is string s && s == content);
[AvaloniaFact]
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
// Sanity: the listing populated a file row.
Assert.NotEmpty(vm.Files);
Assert.NotNull(vm.Files[0].Id);
var downloadBtn = FindRowButton(page, "Télécharger");
Assert.NotNull(downloadBtn);
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
var param = downloadBtn.CommandParameter;
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
"Télécharger must be armed (CanExecute true) for a listed file.");
// Act: click → fire the command.
downloadBtn.Command.Execute(param);
// Assert: the row activated — the API download was invoked
// (it is awaited before the headless-absent save picker).
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
}
[AvaloniaFact]
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
Assert.NotEmpty(vm.Files);
var deleteBtn = FindRowButton(page, "Supprimer");
Assert.NotNull(deleteBtn);
Assert.NotNull(deleteBtn!.Command);
var param = deleteBtn.CommandParameter;
Assert.True(deleteBtn.Command!.CanExecute(param),
"Supprimer must be armed (CanExecute true) for a listed file.");
deleteBtn.Command.Execute(param);
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
}
private sealed class StubFsApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
=> Calls.Add((method, path, body));
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (typeof(T) == typeof(UserDirectoryDto))
{
var dir = new UserDirectoryDto
{
UserName = "alice",
Files = new[]
{
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
}
};
return Task.FromResult((T)(object)dir);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
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 Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,35 +0,0 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// The <see cref="StatusBar"/> surfaces the page's action status
/// (success / warning / error message). The user reported being
/// unable to copy these messages — the status text was a
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
/// now a <see cref="SelectableTextBlock"/> so the message can be
/// selected and copied (Ctrl+C / context menu). This test pins that:
/// if the message control regresses to a plain <c>TextBlock</c>,
/// selectability is lost and the test fails.
/// </summary>
public class StatusBarTests
{
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
var message = bar.GetVisualDescendants()
.OfType<SelectableTextBlock>()
.FirstOrDefault();
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}

View file

@ -13,10 +13,7 @@
FontWeight="Bold"
Foreground="{Binding Foreground}"
VerticalAlignment="Top"/>
<!-- SelectableTextBlock so the user can select + copy the
status message (TextBlock is not selectable in
Avalonia). Keeps Text/TextWrapping/Foreground. -->
<SelectableTextBlock Text="{Binding Message}"
<TextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}"
TextWrapping="Wrap"
VerticalAlignment="Center"/>

View file

@ -49,16 +49,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_bearer = new BearerTokenHandler(this);
Http = new HttpClient(_bearer, disposeHandler: true);
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
// fetch, et Android si libmsquic n'est pas disponible. Tous les
// clients Yavsc.Api.Client partagent cette même instance `Http`.
Http.DefaultRequestVersion = new Version(3, 0);
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
_tokens = store.Load();
}

View file

@ -113,13 +113,13 @@
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DownloadQueryAttachmentCommand}"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DetachQueryFileCommand}"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).CanAttach}" />
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
</Grid>
</Border>
</DataTemplate>

View file

@ -167,11 +167,11 @@
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DownloadAttachmentCommand}"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DetachFileCommand}"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>

View file

@ -15,9 +15,6 @@ using PostIt.Services;
using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands;
using Mapsui.Tiling; // Ensure you have this namespace
public partial class RdvPage : ContentPage
{
@ -47,16 +44,6 @@ public partial class RdvPage : ContentPage
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent();
InitializeMap();
// ... inside your Map setup logic ...
string version = GetType().Assembly.GetName().Version?.ToString() ?? "1.1";
// 1. Create a unique user-agent string for your app (e.g., "AppName/Version (ContactEmail)")
string myCustomUserAgent = $"PostIt/{version} (paul@pschneider.fr)";
// 2. Pass your custom user agent into the OpenStreetMap helper
var osmLayer = OpenStreetMap.CreateTileLayer(myCustomUserAgent);
// 3. add your newly identified layer
_locationMap.Map.Layers.Add(osmLayer);
}
private void InitializeComponent()

View file

@ -3,12 +3,7 @@
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.SessionStatusBanner"
x:DataType="vm:SessionStatusViewModel">
<Design.DataContext>
<vm:SessionStatusViewModel />
</Design.DataContext>
<Border DockPanel.Dock="Top"
<Border DockPanel.Dock="Top"
BorderBrush="#B0BEC5"
BorderThickness="0,0,0,1"
Padding="12,6">

View file

@ -36,7 +36,7 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:DirEntryDto">
<Button Content="📁 {Binding Name}"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).OpenDirCommand}"
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"
Margin="0,0,0,4" />
@ -59,16 +59,16 @@
TextWrapping="NoWrap" />
<Button Grid.Column="1"
Content="Joindre"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).PickFileCommand}"
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).IsPickerMode}" />
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
<Button Grid.Column="3"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DownloadFileCommand}"
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="5"
Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DeleteFileCommand}"
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>

View file

@ -1,6 +1,6 @@
{
"Authentication": {
"Authority": "https://yavsc.pschneider.fr",
"Authority": "https://localhost:5001",
"ClientId": "postit",
"Scopes": [
"blogs",
@ -9,8 +9,13 @@
"RedirectUri": "postit://callback"
},
"DarkMode": true,
"BlogsApiUrl": "https://blogs.pschneider.fr/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/",
"BlogsApiUrl": "https://localhost:5003/api/v1/",
"ApiUrl": "https://localhost:5005/api/v1/",
"SearchText": "",
"ProviderOngoingRequestsSortOption": ""
}
"ProviderOngoingRequestsSortOption": "",
"Loaded": true,
"IsDirty": true,
"CanNavigateNext": false,
"CanNavigatePrevious": true,
"SaveCommand": {}
}

View file

@ -4,7 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Api.Tests</RootNamespace>
<RootNamespace>Yavsc.Api.Test</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
@ -21,10 +21,10 @@
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Api\Yavsc.Api.csproj" />
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Api\Yavsc.Api.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -44,7 +44,7 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string? name = null)
private HttpClient NewClient(string subject = "alice")
{
var handler = new HttpClientHandler
{
@ -54,11 +54,8 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// Pass a distinct name to mirror production (sub = GUID, name =
// login); defaults to subject for the existing tests where the
// two are equal.
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, name: name));
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
return http;
}
@ -132,75 +129,6 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
"Expected the uploaded file on disk under the owner's personal root");
}
/// <summary>
/// Regression for the "Mes fichiers" page listing nothing: the
/// personal-storage tree is keyed by the user's <b>login</b>
/// (<c>UserName</c>) — <c>EnsureDestinationDirectory</c> writes
/// under <c>{Blog}/{name-claim}</c>, the download endpoint and
/// avatar paths use <c>user.UserName</c> — but <c>GET api/v1/fs</c>
/// passed <c>User.GetUserId()</c> (the <c>sub</c> claim, a GUID in
/// production) to <c>GetUserFiles</c>, so the listing looked under
/// <c>{Blog}/{sub-GUID}</c>, an empty tree. This test decouples
/// <c>sub</c> from the login (mirroring production) and asserts the
/// listing returns the file stored under the login.
/// </summary>
[Fact]
public async Task Get_fs_lists_files_stored_under_the_user_login()
{
_fixture.ResetDatabase();
// sub = "alice-sub" (GUID-like), login = "alice" — the shape
// ProfileService mints in production. DiskQuota non-zero so the
// upload persists the UploadedFile row (see Post_fs test).
_fixture.SeedUser("alice", u =>
{
u.Id = "alice-sub";
u.DiskQuota = 10_000_000;
});
var payload = System.Text.Encoding.UTF8.GetBytes("list me");
using var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(payload);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
// Upload with a token whose sub is the GUID-like id and whose
// name is the login — EnsureDestinationDirectory keys off "name".
using (var http = NewClient("alice-sub", name: "alice"))
{
var post = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
var postBody = await post.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(post.StatusCode == HttpStatusCode.OK,
$"Upload failed: {(int)post.StatusCode} ({post.StatusCode}): {postBody}");
}
// The file landed under the login, not the subject.
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
"Expected the uploaded file under {BlogFilesRoot}/alice/note.txt (the login)");
Assert.False(Directory.Exists(Path.Combine(_fixture.BlogFilesRoot, "alice-sub")),
"The subject-named directory must NOT exist — storage is keyed by the login");
// GET /api/v1/fs must list under the login and find the file.
using var get = NewClient("alice-sub", name: "alice");
var response = await get.GetAsync("/api/v1/fs", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"List failed: {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.TryGetProperty("files", out var filesEl),
$"Response has no 'files' property. Body: {body}");
var names = filesEl.EnumerateArray().Select(f => f.GetProperty("name").GetString()).ToArray();
Assert.Contains("note.txt", names);
// The UploadedFile row (OwnerId = sub) is enriched onto the
// entry, so the client can attach the file by reference.
var noteEntry = filesEl.EnumerateArray()
.First(f => f.GetProperty("name").GetString() == "note.txt");
Assert.True(noteEntry.TryGetProperty("id", out var idEl) && idEl.GetInt64() > 0,
"Expected the enriched UploadedFile id on the listed entry");
}
[Fact]
public async Task GetFile_owner_can_download_their_own_file()
{
@ -363,35 +291,4 @@ public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// <summary>
/// Regression for the "delete returns 40x yet the file is gone"
/// report: <c>DeleteUserDirOrFile</c> read <c>FileInfo.Length</c>
/// <b>after</b> <c>fi.Delete()</c>, and <c>FileInfo.Length</c>
/// throws <c>FileNotFoundException</c> once the file no longer
/// exists. The controller's <c>catch</c> turned that into a 400
/// even though the file had already been removed — the user saw
/// "le fichier n'existe pas sur le serveur" while the delete in
/// fact succeeded. This test asserts a clean 200 and that the
/// file is gone from disk.
/// </summary>
[Fact]
public async Task Delete_fs_removes_the_file_and_returns_200()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var bytes = System.Text.Encoding.UTF8.GetBytes("delete me");
WritePhysicalFile(_fixture, "alice", "doomed.txt", bytes);
using var http = NewClient("alice");
var response = await http.DeleteAsync(
"/api/v1/fs/doomed.txt", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
Assert.False(File.Exists(PhysicalFile(_fixture, "alice", "doomed.txt")),
"The file must be gone from disk after a successful delete.");
}
}

View file

@ -23,13 +23,13 @@
<PackageReference Include="xunit.v3.extensibility.core" />
</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.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" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
</Project>

View file

@ -54,18 +54,7 @@ namespace Yavsc.Blogs.Controllers
{
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
var uid = User.GetUserId();
// The personal-storage tree is keyed by the user's login
// (UserName): EnsureDestinationDirectory writes under
// {Blog}/{UserName}, the download endpoint and avatar paths
// use user.UserName too. GetUserId() returns the "sub" claim
// (a GUID in production), so passing uid here made the listing
// look under {Blog}/{sub-GUID} — an empty tree, hence the
// "Mes fichiers" page listing nothing. Resolve the login and
// list under it. OwnerId on UploadedFile stays "sub" (uid),
// so EnrichWithFileIds still queries by uid.
var user = dbContext.Users.SingleOrDefault(u => u.Id == uid);
if (user == null) return NotFound();
var files = FileSystemHelpers.GetUserFiles(siteSettings, user.UserName, subdir);
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
EnrichWithFileIds(files, uid, subdir);
return Ok(files);
}

View file

@ -26,10 +26,10 @@
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://blogs.pschneider.fr"
"Url": "http://localhost:5002"
},
"Https": {
"Url": "https://blogs.pschneider.fr"
"Url": "https://localhost:5003"
}
}
}

View file

@ -1,10 +1,7 @@
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using IdentityModel.Client;
using Xunit;
using Yavsc.Server.Helpers;
namespace Yavsc.Org.Tests
{
@ -73,72 +70,6 @@ namespace Yavsc.Org.Tests
}
/// <summary>
/// Regression for the MyFiles upload 500 / null
/// <c>User.Identity.Name</c>: the access token minted by the
/// real IdentityServer (resource-owner password grant) must
/// carry a <c>name</c> claim whose value is the user's login
/// (<c>UserName</c>), and <see cref="UserHelpers.GetUserName"/>
/// — which reads <c>FindFirstValue("name")</c> — must resolve
/// to it on a principal built the way
/// <c>AddYavscJwtBearer</c> builds one
/// (<c>MapInboundClaims=false</c>, <c>NameClaimType="name"</c>).
///
/// <para>Prerequisites wired by <see cref="WebServerFixture"/>:
/// the test client's <c>AllowedScopes</c> include
/// <c>openid</c>+<c>profile</c>, and the standard identity
/// resources are seeded by the production pipeline. Requesting
/// <c>openid profile</c> makes <c>ProfileService</c> emit
/// <c>name</c> = <c>user.UserName</c>.</para>
/// </summary>
[Fact]
public async Task PasswordToken_carries_name_claim_equal_to_login()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
var client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
// "openid profile" triggers ProfileService to emit the
// "name" claim (user.UserName). Without profile, the
// token carries only sub + scopes and GetUserName()
// resolves to null — the exact regression behind the
// MyFiles upload 500. "test" is the API scope, so a real
// JWT access token (not just an id_token) is issued.
Scope = "openid profile test"
}, cancellationToken);
Assert.False(response.IsError,
$"Token request failed: {response.Error} ({response.ErrorDescription})");
// Decode the access token straight from the payload so short
// claim names ("name", "sub") stay as-emitted — mirrors
// AddYavscJwtBearer with MapInboundClaims=false.
var jwt = new JwtSecurityToken(response.AccessToken);
var nameClaim = jwt.Claims.FirstOrDefault(c => c.Type == "name");
Assert.True(nameClaim is not null,
"access_token has no 'name' claim; ProfileService did not emit it " +
"(is the 'profile' scope in the client's AllowedScopes and requested?).");
Assert.Equal(_serverFixture.TestingUserName, nameClaim!.Value);
// Rebuild the principal the way a bearer consumer host does
// and exercise the real GetUserName() helper used by the
// file-system / avatar paths.
var identity = new ClaimsIdentity(
jwt.Claims, authenticationType: "Bearer",
nameType: "name", roleType: null);
var principal = new ClaimsPrincipal(identity);
Assert.Equal(_serverFixture.TestingUserName, principal.GetUserName());
}
[Fact]
public async Task GetSignin_returns_a_page()
{

View file

@ -8,16 +8,6 @@ using Xunit;
namespace Yavsc
{
// BillingService and Config expose static mutable registries that
// WorkflowHelpers.ConfigureBillingService() also mutates from the
// WebServerFixture host build (HostingExtensions.cs). Without a
// collection, xUnit runs this class in parallel with the
// "Yavsc Server" collection, and the host build's ConfigureBilling
// call can land between this test's two ConfigureBilling calls,
// breaking the idempotency count assertion (a flaky failure that
// surfaces whenever the fixture's timing shifts). Pinning the class
// to the same collection serializes it with the host build.
[Collection("Yavsc Server")]
[Trait("regression", "II")]
public class BillingServiceTests
{

View file

@ -1,5 +1,4 @@
using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.EntityFramework.Mappers;
using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
@ -239,26 +238,6 @@ public sealed class WebServerFixture : WebHostFixture
AddAuthorizedClient(scope, TestClientId, TestClientSecret);
}
// EnsureDefaultApplicationScopes seeds the standard identity
// resources (openid, profile) once at host startup, but a
// ResetAndMigrateDatabase call does EnsureDeleted on the shared
// in-memory store, which wipes them — and ReseedAuthTestData
// previously only re-added the client. Without openid/profile
// present, any token request asking for those scopes returns
// invalid_scope, so a password-grant token carrying the "name"
// claim (scope "openid profile") could not be minted after a
// reset. Re-seed them here to mirror the startup seed.
var identityResources = configDb.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
if (!identityResources.Any(r => r.Name == "openid"))
{
identityResources.Add(new IdentityResources.OpenId().ToEntity());
}
if (!identityResources.Any(r => r.Name == "profile"))
{
identityResources.Add(new IdentityResources.Profile().ToEntity());
}
configDb.SaveChanges();
_sharedTestClientId = TestClientId;
_sharedTestClientSecret = TestClientSecret;
_sharedTestingUserName = TestingUserName;
@ -305,12 +284,7 @@ public sealed class WebServerFixture : WebHostFixture
{
ClientId = testClientId,
AccessTokenLifetime = 3600000,
// AccessTokenType = 0 → Jwt in IdentityServer8 (1 = Reference,
// the inverse of Duende's enum). The real PostIt client issues
// JWT access tokens so the Blogs/Api bearer hosts can validate
// them with JwtBearer; mirror that here so the token can be
// decoded and its "name" claim asserted.
AccessTokenType = 0,
AccessTokenType = 1,
ClientName = "Testing client",
Enabled = true,
RequireClientSecret = true
@ -353,23 +327,6 @@ public sealed class WebServerFixture : WebHostFixture
ClientId = testingClient.Id,
Scope = "test"
});
// Allow the identity scopes so a resource-owner-password token
// can request "openid profile" and trigger ProfileService to
// emit the "name" claim (user.UserName). Without profile in the
// client's AllowedScopes, IdentityServer strips the scope from
// the request and the access_token carries no "name" — the
// regression that left GetUserName() / the MyFiles upload root
// null. Additive only: existing tests request "test" alone.
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "openid"
});
configDb.Set<ClientScope>().Add(new ClientScope
{
ClientId = testingClient.Id,
Scope = "profile"
});
configDb.SaveChanges();
}

View file

@ -42,9 +42,9 @@
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -368,17 +368,6 @@ public static class HostingExtensions
})
.AddAspNetIdentity<ApplicationUser>()
// Register the custom ProfileService so IdentityServer actually
// calls it instead of the default UserProfileService. Without
// this, the class is dead code: IS8 falls back to the default,
// which only emits claims listed in each API resource's
// UserClaims (RequestedClaimTypes) — "name" is never among
// them, so every access token lacks "name" and the Blogs/Api
// bearer hosts get GetUserName() == null (the MyFiles upload
// 500). Our ProfileService emits name/email/roles for any
// user grant whose client is allowed "profile", independent
// of the caller, so the access token carries the login.
.AddProfileService<ProfileService>()
.AddClientStore<ClientStore>()
.AddClientConfigurationValidator<DefaultClientConfigurationValidator>()
.AddCorsPolicyService<CorsPolicyService>()

View file

@ -21,9 +21,9 @@
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -1,17 +1,19 @@
#nullable enable annotations
using System.Security.Claims;
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.FileProviders;
using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models;
using Yavsc.Models.Streaming;
using Yavsc.ViewModels;
using Microsoft.AspNetCore.Http;
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
@ -44,7 +46,7 @@ namespace Yavsc.Server.Helpers
this ClaimsPrincipal user,
string subpath, SiteSettings siteSettings)
{
var root = Path.Combine(siteSettings.Blog, user.GetUserName());
var root = Path.Combine(siteSettings.Blog, user.Identity.Name);
if (!string.IsNullOrWhiteSpace(subpath))
{
if (!subpath.IsValidYavscPath())
@ -70,12 +72,8 @@ namespace Yavsc.Server.Helpers
var root = Path.Combine(siteSettings.Blog, user.UserName);
var fi = new FileInfo(Path.Combine(root, fileName));
if (!fi.Exists) return;
// Capture the length BEFORE deleting: FileInfo.Length throws
// FileNotFoundException once the file is gone, which would
// surface as a 400 even though the delete already succeeded.
var length = fi.Length;
fi.Delete();
user.DiskUsage -= length;
user.DiskUsage -= fi.Length;
}
public static FsOperationInfo DeleteUserDirOrFile(this ApplicationUser user, string dirName, SiteSettings siteSettings)
@ -89,15 +87,8 @@ namespace Yavsc.Server.Helpers
{
var fi = new FileInfo(Path.Combine(root, dirName));
if (!fi.Exists) return new FsOperationInfo { Done = false, ErrorCode = ErrorCode.NotFound, ErrorMessage = "non existent" };
// Capture the length BEFORE deleting: FileInfo.Length
// throws FileNotFoundException once the file is gone, so
// reading it after fi.Delete() would throw — the caller's
// catch turns that into a 400 even though the file was
// already removed (the user sees "file does not exist"
// while the delete in fact succeeded).
var length = fi.Length;
fi.Delete();
user.DiskUsage -= length;
user.DiskUsage -= fi.Length;
}
else
{

View file

@ -1,6 +1,5 @@
#nullable enable annotations
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
@ -108,23 +107,9 @@ public static class ServiceExtensions
ValidateAudience = true,
ValidAudiences = audiences,
RoleClaimType = Constants.RoleClaimType,
// IdentityServer's ProfileService emits the name claim as
// JwtClaimTypes.Name ("name", short form). MapInboundClaims
// (below) is disabled, so it stays "name" on the principal —
// NameClaimType must therefore be "name" too, or
// User.Identity.Name / GetUserName() resolve to null. The
// previous Constants.NameClaimType (the Microsoft long URI)
// never matched the remapped xmlsoap URI either, so the name
// was unreachable on every bearer host regardless of scope.
NameClaimType = JwtClaimTypes.Name,
NameClaimType = Constants.NameClaimType,
};
// Keep JWT claim names as-emitted ("sub", "name", …). The default
// inbound map rewrites "name" → xmlsoap name URI and "sub" →
// nameid URI, which neither Constants.NameClaimType (microsoft
// URI) nor GetUserName()'s FindFirstValue("name") could match.
// Roles are already emitted as Constants.RoleClaimType (a long
// URI), so disabling the map leaves IsInMsRole() working.
options.MapInboundClaims = false;
options.MapInboundClaims = true;
options.ClaimsIssuer = authority;
options.Audience = audiences[0];

View file

@ -28,40 +28,35 @@ namespace Yavsc.Services
var claims = new List<Claim> {
new Claim(JwtClaimTypes.Subject,user.Id.ToString()),
};
List<string> claimAdds = new List<string>();
foreach (var scope in context.RequestedResources.ParsedScopes)
{
if (context.Client.AllowedScopes.Contains(scope.ParsedName))
{
claims.Add(new Claim(JwtClaimTypes.Scope, scope.ParsedName));
claimAdds.Add(scope.ParsedName);
}
}
// name / email / roles are gated on the client being granted
// the "profile" identity scope. They MUST land in the *access
// token*: the Blogs/Api bearer resource hosts read the login
// via GetUserName() (FindFirstValue("name")) and roles via
// IsInMsRole() from the access token. But "profile" is an
// identity scope, which only appears in the id-token caller's
// ParsedScopes — never in the access-token caller's — so the
// previous gate (claimAdds.Contains("profile")) left name,
// email and roles out of every access token, and GetUserName()
// resolved to null on every bearer host (the MyFiles upload
// 500). Gate on the client's AllowedScopes instead, which is
// caller-independent, so both the access token and the id
// token carry these claims for any user grant whose client is
// allowed "profile".
if (context.Client.AllowedScopes.Contains(JwtClaimTypes.Profile))
if (claimAdds.Contains(JwtClaimTypes.Profile))
{
// "name" carries the login (UserName), not the display
// FullName: GetUserName() / User.Identity.Name feed
// filesystem paths (user files root, avatar file name),
// and FullName can contain spaces. No bearer-host
// consumer displays this claim as a human name today.
claims.Add(new Claim(JwtClaimTypes.Name, user.UserName));
claimAdds.Remove("profile");
claimAdds.Add(JwtClaimTypes.Name);
claimAdds.Add(JwtClaimTypes.Email);
claimAdds.Add(Constants.RoleClaimType);
}
if (claimAdds.Contains(JwtClaimTypes.Name))
claims.Add(new Claim(JwtClaimTypes.Name, user.FullName));
if (claimAdds.Contains(JwtClaimTypes.Email))
claims.Add(new Claim(JwtClaimTypes.Email, user.Email));
if (claimAdds.Contains(Constants.RoleClaimType))
{
var roles = await this._userManager.GetRolesAsync(user);
if (roles.Count() > 0)
if (roles.Count()>0)
{
claims.AddRange(roles.Select(r => new Claim(Constants.RoleClaimType, r)));
}

View file

@ -63,32 +63,17 @@ public static class TestTokenIssuer
/// <param name="scope">Value of the <c>scope</c> claim. The
/// production <c>BlogScope</c> policy requires
/// <c>RequireClaim("scope", "blogs")</c>.</param>
/// <param name="name">Value of the <c>name</c> claim. Defaults to
/// <paramref name="subject"/> (the test convention where the login
/// equals the subject). Pass a distinct login to mirror production,
/// where <c>sub</c> is a GUID and <c>name</c> is the user's
/// <c>UserName</c> — the file-system list/upload paths key the
/// personal-storage tree off the login, not the subject.</param>
/// <param name="extraClaims">Optional additional claims
/// (e.g. a role for an admin-bypass test).</param>
public static string Issue(
string subject,
string scope = "blogs",
string? name = null,
IEnumerable<Claim>? extraClaims = null)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new("sub", subject),
// Mirror production: IdentityServer's ProfileService emits the
// "name" claim as the user's login (UserName) when the profile
// scope is granted, and the file-system helpers resolve the
// per-user storage root via GetUserName() (FindFirstValue("name"))
// — not via User.Identity.Name. A test token without "name" would
// make ReceiveUserFile / EnsureDestinationDirectory compute a
// null owner root and 500 on upload, so stamp it here.
new("name", name ?? subject),
new("scope", scope),
};
if (extraClaims is not null) claims.AddRange(extraClaims);

Some files were not shown because too many files have changed in this diff Show more