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,13 +39,18 @@
<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}"/>
<StackPanel Grid.Row="12" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Choisir l'avatar"
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings <!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command" name matches the source method exactly (no "Command"
@ -54,10 +59,10 @@
de binding pour [RelayCommand]" for the full rationale. de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. --> when there's nothing to persist. -->
<Button Grid.Row="12" Content="Sauver" <Button Content="Sauver"
HorizontalAlignment="Right"
Command="{Binding Save}" Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/> 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)
{
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(); await _dbContext.SaveChangesAsync();
return Ok(info); 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;
Dropzone.options.postavatar = {
maxFilesize: 2, // MB (an avatar) function setStatus(message, isError) {
autoProcessQueue: true, if (!statusNode) return;
accept: function(file, done) { statusNode.textContent = message || '';
if (file.name == "justinbieber.jpg") { statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
done("Naha, you don't.");
} }
else { done(); }
}, Dropzone.options.postavatar = {
maxFilesize: @maxAvatarSizeMb, // MB
acceptedFiles: "@acceptedAvatarMimeTypes",
maxFiles: 1,
addRemoveLinks: true,
autoProcessQueue: true,
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">