setup the avatar

This commit is contained in:
Paul Schneider 2026-09-04 20:25:06 +01:00
commit 343538a7ea
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
14 changed files with 300 additions and 92 deletions

View file

@ -67,7 +67,7 @@ public class BearerScopeTests
Scopes = userScopes, Scopes = userScopes,
RedirectUri = "postit://callback", RedirectUri = "postit://callback",
}, },
BusinessApiUrl = "https://example.invalid/api/v1/", ApiUrl = "https://example.invalid/api/v1/",
}; };
var tokensPath = Path.Combine( var tokensPath = Path.Combine(
@ -266,7 +266,7 @@ public class BearerScopeTests
// private HttpClient is independent, so we resolve the // private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl — // absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress. // the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute); using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization = req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);

View file

@ -95,7 +95,7 @@ public class PostAclDialogTests
HttpMethod method, string path, object? body = null, HttpMethod method, string path, object? body = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path); var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute); using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
@ -123,8 +123,8 @@ public class PostAclDialogTests
var handler = new CountingHttpHandler(); var handler = new CountingHttpHandler();
var settings = new Settings(); var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler); var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl); var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl); var circleClient = new CircleApiClient(api, settings.ApiUrl);
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton(settings); services.AddSingleton(settings);

View file

@ -89,7 +89,7 @@ public class SettingsLoadTests
settings.Authentication.RedirectUri = settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri; global::AuthenticationSettings.DesktopRedirectUri;
settings.BusinessApiUrl = flip settings.ApiUrl = flip
? "https://a.example.test/api/v1/" ? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/"; : "https://b.example.test/api/v1/";

View file

@ -58,7 +58,7 @@ public class YavscApiClientTests
// calls CallAsync("posts", ...) directly (bypassing // calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set // BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here. // it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl); reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>( var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken); HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -118,7 +118,7 @@ public class YavscApiClientTests
RedirectUri = "postit://callback", RedirectUri = "postit://callback",
Scopes = new[] { "openid" }, Scopes = new[] { "openid" },
}, },
BusinessApiUrl = "https://127.0.0.1:5003/api/v1", ApiUrl = "https://127.0.0.1:5003/api/v1",
}; };
var client = new YavscApiClient(settings, new TokenStore(Path.Combine( var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -162,7 +162,7 @@ public class YavscApiClientTests
RedirectUri = authority.LoopbackRedirectUri, RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" } Scopes = new[] { "openid", "profile", "blog" }
}, },
BusinessApiUrl = apiBaseUrl ApiUrl = apiBaseUrl
}; };
private static async Task<YavscApiClient> LoginAndPersistAsync( private static async Task<YavscApiClient> LoginAndPersistAsync(
@ -175,7 +175,7 @@ public class YavscApiClientTests
// directly (bypassing BlogApiClient) rely on the same // directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's // BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub. // ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl); client.Http.BaseAddress = new Uri(settings.ApiUrl);
// Force the API client to use the test browser by routing the // Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper. // LoginInteractiveAsync call through a small wrapper.

View file

@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl); var activityClient = new ActivityApiClient(api, settings.ApiUrl);
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl); var billingClient = new BillingApiClient(api, settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
// Vues // Vues

View file

@ -1,4 +1,5 @@
using System; using System;
using System.IO;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@ -351,6 +352,56 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_store.Save(_tokens); _store.Save(_tokens);
} }
/// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image
/// content type before persisting it.
/// </summary>
public async Task SetAvatarAsync(
Stream imageStream,
string fileName,
string? contentType = null,
CancellationToken ct = default)
{
if (imageStream is null)
throw new ArgumentNullException(nameof(imageStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentException("A file name is required.", nameof(fileName));
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var attemptUpload = async () =>
{
if (imageStream.CanSeek)
imageStream.Position = 0;
using var content = new MultipartFormDataContent();
using var fileContent = new StreamContent(imageStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content,
};
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_store.Clear(); _store.Clear();

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut; using Yavsc.Models.Haircut;
namespace PostIt.ViewModels.Commands; namespace PostIt.ViewModels.Commands;
@ -21,7 +22,6 @@ public partial class BrushViewModel : RdvViewModel
[ObservableProperty] [ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; } public partial HairPrestationDto? SelectedPrestation { get; set; }
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient) public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient) : base(activity, performer, form, billingClient)
{ {

View file

@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty] [ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
[ObservableProperty] [ObservableProperty]
public partial string SearchText { get; set; } = string.Empty; public partial string SearchText { get; set; } = string.Empty;
@ -45,7 +45,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty(); partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary> /// <summary>
@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl) this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl ? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl; : legacyApiUrl ?? this.BlogsApiUrl;
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl) this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
? settings.BusinessApiUrl ? settings.ApiUrl
: this.BusinessApiUrl; : this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty; this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null)) if (!(settings.Authentication is null))
{ {
@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase
}; };
this.DarkMode = false; this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/"; this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/"; this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty; this.SearchText = string.Empty;
} }

View file

@ -33,13 +33,11 @@
<TextBlock Grid.Row="5" <TextBlock Grid.Row="5"
Text="Motif" Text="Motif"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,12,8" Margin="0,0,12,8" />
IsVisible="{Binding ShowsReason}" />
<TextBox Grid.Row="5" <TextBox Grid.Row="5"
Grid.Column="1" Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}" Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" Margin="0,0,0,8" />
IsVisible="{Binding ShowsReason}" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" /> <TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" /> <TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
@ -60,14 +58,12 @@
<TextBlock Grid.Row="10" <TextBlock Grid.Row="10"
Text="Prestation" Text="Prestation"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,12,8" Margin="0,0,12,8" />
IsVisible="{Binding ShowsSinglePrestation}" />
<ComboBox Grid.Row="10" <ComboBox Grid.Row="10"
Grid.Column="1" Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}" ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}" SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8" Margin="0,0,0,8">
IsVisible="{Binding ShowsSinglePrestation}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Spacing="2"> <StackPanel Spacing="2">
@ -78,39 +74,14 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<TextBlock Grid.Row="11"
Text="Prestations"
VerticalAlignment="Top"
Margin="0,0,12,8"
IsVisible="{Binding ShowsMultiplePrestations}" />
<ListBox Grid.Row="11"
Grid.Column="1"
ItemsSource="{Binding MultiPrestations}"
IsVisible="{Binding ShowsMultiplePrestations}"
MaxHeight="180"
Margin="0,0,0,8">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Margin="0,0,0,8">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="12" <TextBlock Grid.Row="12"
Text="Informations" Text="Informations"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,12,8" Margin="0,0,12,8" />
IsVisible="{Binding ShowsAdditionalInfo}" />
<TextBox Grid.Row="12" <TextBox Grid.Row="12"
Grid.Column="1" Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}" Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" Margin="0,0,0,8" />
IsVisible="{Binding ShowsAdditionalInfo}" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2" <CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande" Content="Je consens à la création de cette commande"

View file

@ -29,13 +29,11 @@
<TextBlock Grid.Row="5" <TextBlock Grid.Row="5"
Text="Motif" Text="Motif"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,12,8" Margin="0,0,12,8" />
IsVisible="{Binding ShowsReason}" />
<TextBox Grid.Row="5" <TextBox Grid.Row="5"
Grid.Column="1" Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}" Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" Margin="0,0,0,8" />
IsVisible="{Binding ShowsReason}" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" /> <TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" /> <TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
@ -57,13 +55,11 @@
<TextBlock Grid.Row="12" <TextBlock Grid.Row="12"
Text="Informations" Text="Informations"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,12,8" Margin="0,0,12,8" />
IsVisible="{Binding ShowsAdditionalInfo}" />
<TextBox Grid.Row="12" <TextBox Grid.Row="12"
Grid.Column="1" Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}" Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" Margin="0,0,0,8" />
IsVisible="{Binding ShowsAdditionalInfo}" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2" <CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande" Content="Je consens à la création de cette commande"

View file

@ -39,25 +39,30 @@
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox" <TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/> Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Business API URL"/> <TextBlock Grid.Row="8" Text="API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox" <TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/> Text="{Binding ApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/> <TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/> <CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings <StackPanel Grid.Row="12" Orientation="Horizontal" HorizontalAlignment="Right">
VM. The source generator emits an ICommand property whose <Button Content="Choisir l'avatar"
name matches the source method exactly (no "Command" Click="ChooseAvatar_Click"
suffix is added), so we bind {Binding Save} here. See Margin="0,0,8,0"/>
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale. <!-- Sauver: bound to the Save RelayCommand on the Settings
IsEnabled tracks IsDirty so the button auto-disables VM. The source generator emits an ICommand property whose
when there's nothing to persist. --> name matches the source method exactly (no "Command"
<Button Grid.Row="12" Content="Sauver" suffix is added), so we bind {Binding Save} here. See
HorizontalAlignment="Right" AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
Command="{Binding Save}" de binding pour [RelayCommand]" for the full rationale.
IsEnabled="{Binding IsDirty}"/> IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</Grid> </Grid>
</ContentPage> </ContentPage>

View file

@ -1,6 +1,12 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
namespace PostIt.Views; namespace PostIt.Views;
public partial class SettingsPage: ContentPage public partial class SettingsPage: ContentPage
@ -9,4 +15,56 @@ public partial class SettingsPage: ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
}
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
return;
}
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir un avatar",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
}
}
});
var file = files.FirstOrDefault();
if (file is null)
{
return;
}
try
{
await using var stream = await file.OpenReadAsync();
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
}
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "application/octet-stream"
};
}
}

View file

@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Api.Helpers; using Yavsc.Api.Helpers;
@ -14,6 +16,17 @@ namespace Yavsc.WebApi.Controllers
[Authorize("ApiScope")] [Authorize("ApiScope")]
public class ApiAccountController : Controller public class ApiAccountController : Controller
{ {
private const long MaxAvatarSizeBytes = 2 * 1024 * 1024;
private static readonly string[] AcceptedAvatarMimeTypes =
[
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
];
readonly ApplicationDbContext _dbContext; readonly ApplicationDbContext _dbContext;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -70,14 +83,93 @@ namespace Yavsc.WebApi.Controllers
public async Task<IActionResult> SetAvatar() public async Task<IActionResult> SetAvatar()
{ {
var user = await GetUserData(User.GetUserId()); var user = await GetUserData(User.GetUserId());
if (Request.Form.Files.Count!=1) if (!Request.HasFormContentType)
return new BadRequestResult(); {
if (!Request.Form.Files[0].ContentType.StartsWith("image/png")) return BadRequest(new
return new BadRequestResult(); {
status = "invalid_request",
message = "A multipart/form-data request is required.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
var info = user.ReceiveAvatar(Request.Form.Files[0]); if (Request.Form.Files.Count != 1)
await _dbContext.SaveChangesAsync(); {
return Ok(info); return BadRequest(new
{
status = "invalid_file_count",
message = "Exactly one file is required.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
var avatarFile = Request.Form.Files[0];
if (avatarFile.Length <= 0)
{
return BadRequest(new
{
status = "empty_file",
message = "The uploaded file is empty.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
if (avatarFile.Length > MaxAvatarSizeBytes)
{
return BadRequest(new
{
status = "file_too_large",
message = "Avatar is too large.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
if (!AcceptedAvatarMimeTypes.Any(m => string.Equals(m, avatarFile.ContentType, StringComparison.OrdinalIgnoreCase)))
{
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new
{
status = "unsupported_media_type",
message = "Unsupported image format.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
try
{
var info = user.ReceiveAvatar(avatarFile);
await _dbContext.SaveChangesAsync();
return Ok(new
{
status = "uploaded",
message = "Avatar uploaded successfully.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
avatar = info,
});
}
catch (MagickException ex)
{
_logger.LogWarning(ex, "Avatar upload failed: invalid image data for user {UserId}", user.Id);
return BadRequest(new
{
status = "invalid_image_data",
message = "Image content could not be decoded.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
} }
[HttpGet("identity")] [HttpGet("identity")]

View file

@ -4,6 +4,9 @@
var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl) var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl)
? string.Empty ? string.Empty
: SiteSettings.Value.ApiUrl.TrimEnd('/'); : SiteSettings.Value.ApiUrl.TrimEnd('/');
const int maxAvatarSizeMb = 2;
var acceptedAvatarFormats = new[] { "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff" };
var acceptedAvatarMimeTypes = "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff";
var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl) var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl)
? "/api/v1/account/set-avatar" ? "/api/v1/account/set-avatar"
: $"{apiBaseUrl}/api/v1/account/set-avatar"; : $"{apiBaseUrl}/api/v1/account/set-avatar";
@ -21,31 +24,63 @@
(() => { (() => {
const bearer = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(accessToken)); const bearer = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(accessToken));
const uploadUrlBase = "@setAvatarUrl"; const uploadUrlBase = "@setAvatarUrl";
const statusNode = document.getElementById('avatar-upload-status');
const uploadUrl = bearer const uploadUrl = bearer
? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer) ? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer)
: uploadUrlBase; : uploadUrlBase;
function setStatus(message, isError) {
if (!statusNode) return;
statusNode.textContent = message || '';
statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
}
Dropzone.options.postavatar = { Dropzone.options.postavatar = {
maxFilesize: 2, // MB (an avatar) maxFilesize: @maxAvatarSizeMb, // MB
acceptedFiles: "@acceptedAvatarMimeTypes",
maxFiles: 1,
addRemoveLinks: true,
autoProcessQueue: true, autoProcessQueue: true,
accept: function(file, done) {
if (file.name == "justinbieber.jpg") {
done("Naha, you don't.");
}
else { done(); }
},
url: uploadUrl, url: uploadUrl,
init: function() { init: function() {
this.on('sending', function(file, xhr) { this.on('sending', function(file, xhr) {
setStatus('Upload en cours...', false);
if (bearer) { if (bearer) {
xhr.setRequestHeader('Authorization', 'Bearer ' + bearer); xhr.setRequestHeader('Authorization', 'Bearer ' + bearer);
} }
}); });
this.on('success', function(file, response) {
const msg = response && response.message
? response.message
: 'Avatar mis a jour.';
setStatus(msg + ' Format de sortie: PNG.', false);
});
this.on('error', function(file, errorMessage, xhr) {
let message = 'Echec de l\'upload avatar.';
if (xhr && xhr.responseText) {
try {
const payload = JSON.parse(xhr.responseText);
if (payload && payload.message) {
message = payload.message;
}
} catch (_) {
}
} else if (typeof errorMessage === 'string' && errorMessage.length > 0) {
message = errorMessage;
}
setStatus(message + ' Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille max: @maxAvatarSizeMb MB.', true);
});
} }
}; };
})(); })();
</script> </script>
} }
<img src="@previewAvatarSrc"> <img src="@previewAvatarSrc">
<p>Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille maximale: @maxAvatarSizeMb MB. L'image finale est en PNG.</p>
<p id="avatar-upload-status" aria-live="polite"></p>
<form id="postavatar" action="@setAvatarUrl" class="dropzone" method="post" enctype="multipart/form-data"> <form id="postavatar" action="@setAvatarUrl" class="dropzone" method="post" enctype="multipart/form-data">
<div class="fallback"> <div class="fallback">