setup the avatar
This commit is contained in:
parent
b820348558
commit
343538a7ea
14 changed files with 300 additions and 92 deletions
|
|
@ -67,7 +67,7 @@ public class BearerScopeTests
|
|||
Scopes = userScopes,
|
||||
RedirectUri = "postit://callback",
|
||||
},
|
||||
BusinessApiUrl = "https://example.invalid/api/v1/",
|
||||
ApiUrl = "https://example.invalid/api/v1/",
|
||||
};
|
||||
|
||||
var tokensPath = Path.Combine(
|
||||
|
|
@ -266,7 +266,7 @@ public class BearerScopeTests
|
|||
// private HttpClient is independent, so we resolve the
|
||||
// absolute URI ourselves from Settings.BusinessApiUrl —
|
||||
// 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);
|
||||
req.Headers.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ public class PostAclDialogTests
|
|||
HttpMethod method, string path, object? body = null,
|
||||
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 resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
|
@ -123,8 +123,8 @@ public class PostAclDialogTests
|
|||
var handler = new CountingHttpHandler();
|
||||
var settings = new Settings();
|
||||
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
|
||||
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
|
||||
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.ApiUrl);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(settings);
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ public class SettingsLoadTests
|
|||
settings.Authentication.RedirectUri =
|
||||
global::AuthenticationSettings.DesktopRedirectUri;
|
||||
|
||||
settings.BusinessApiUrl = flip
|
||||
settings.ApiUrl = flip
|
||||
? "https://a.example.test/api/v1/"
|
||||
: "https://b.example.test/api/v1/";
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public class YavscApiClientTests
|
|||
// calls CallAsync("posts", ...) directly (bypassing
|
||||
// BlogApiClient, which is the only thing that would set
|
||||
// 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>>(
|
||||
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
||||
|
|
@ -118,7 +118,7 @@ public class YavscApiClientTests
|
|||
RedirectUri = "postit://callback",
|
||||
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(
|
||||
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
||||
|
|
@ -162,7 +162,7 @@ public class YavscApiClientTests
|
|||
RedirectUri = authority.LoopbackRedirectUri,
|
||||
Scopes = new[] { "openid", "profile", "blog" }
|
||||
},
|
||||
BusinessApiUrl = apiBaseUrl
|
||||
ApiUrl = apiBaseUrl
|
||||
};
|
||||
|
||||
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
||||
|
|
@ -175,7 +175,7 @@ public class YavscApiClientTests
|
|||
// directly (bypassing BlogApiClient) rely on the same
|
||||
// BaseAddress the production chain sets in BlogApiClient's
|
||||
// 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
|
||||
// LoginInteractiveAsync call through a small wrapper.
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers
|
|||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
|
||||
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
|
||||
var activityClient = new ActivityApiClient(api, settings.ApiUrl);
|
||||
var billingClient = new BillingApiClient(api, settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
|
||||
// Vues
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
|
@ -351,6 +352,56 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
_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()
|
||||
{
|
||||
_store.Clear();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Haircut;
|
||||
namespace PostIt.ViewModels.Commands;
|
||||
|
||||
|
|
@ -21,7 +22,6 @@ public partial class BrushViewModel : RdvViewModel
|
|||
[ObservableProperty]
|
||||
public partial HairPrestationDto? SelectedPrestation { get; set; }
|
||||
|
||||
|
||||
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
|
||||
: base(activity, performer, form, billingClient)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
|
|||
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
||||
|
||||
[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]
|
||||
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 OnBlogsApiUrlChanged(string value) => MarkDirty();
|
||||
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
||||
partial void OnApiUrlChanged(string value) => MarkDirty();
|
||||
partial void OnSearchTextChanged(string value) => MarkDirty();
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase
|
|||
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
|
||||
? settings.BlogsApiUrl
|
||||
: legacyApiUrl ?? this.BlogsApiUrl;
|
||||
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
|
||||
? settings.BusinessApiUrl
|
||||
: this.BusinessApiUrl;
|
||||
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
|
||||
? settings.ApiUrl
|
||||
: this.ApiUrl;
|
||||
this.SearchText = settings.SearchText ?? string.Empty;
|
||||
if (!(settings.Authentication is null))
|
||||
{
|
||||
|
|
@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase
|
|||
};
|
||||
this.DarkMode = false;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,13 +33,11 @@
|
|||
<TextBlock Grid.Row="5"
|
||||
Text="Motif"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="5"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Reason, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,0,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" />
|
||||
|
|
@ -60,14 +58,12 @@
|
|||
<TextBlock Grid.Row="10"
|
||||
Text="Prestation"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsSinglePrestation}" />
|
||||
Margin="0,0,12,8" />
|
||||
<ComboBox Grid.Row="10"
|
||||
Grid.Column="1"
|
||||
ItemsSource="{Binding AvailablePrestations}"
|
||||
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsSinglePrestation}">
|
||||
Margin="0,0,0,8">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Spacing="2">
|
||||
|
|
@ -78,39 +74,14 @@
|
|||
</ComboBox.ItemTemplate>
|
||||
</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"
|
||||
Text="Informations"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="12"
|
||||
Grid.Column="1"
|
||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||
Content="Je consens à la création de cette commande"
|
||||
|
|
|
|||
|
|
@ -29,13 +29,11 @@
|
|||
<TextBlock Grid.Row="5"
|
||||
Text="Motif"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="5"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Reason, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,0,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" />
|
||||
|
|
@ -57,13 +55,11 @@
|
|||
<TextBlock Grid.Row="12"
|
||||
Text="Informations"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="12"
|
||||
Grid.Column="1"
|
||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||
Content="Je consens à la création de cette commande"
|
||||
|
|
|
|||
|
|
@ -39,25 +39,30 @@
|
|||
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
|
||||
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"
|
||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||
Text="{Binding ApiUrl, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
||||
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||
|
||||
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
||||
VM. The source generator emits an ICommand property whose
|
||||
name matches the source method exactly (no "Command"
|
||||
suffix is added), so we bind {Binding Save} here. See
|
||||
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
|
||||
de binding pour [RelayCommand]" for the full rationale.
|
||||
IsEnabled tracks IsDirty so the button auto-disables
|
||||
when there's nothing to persist. -->
|
||||
<Button Grid.Row="12" Content="Sauver"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{Binding Save}"
|
||||
IsEnabled="{Binding IsDirty}"/>
|
||||
<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
|
||||
VM. The source generator emits an ICommand property whose
|
||||
name matches the source method exactly (no "Command"
|
||||
suffix is added), so we bind {Binding Save} here. See
|
||||
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
|
||||
de binding pour [RelayCommand]" for the full rationale.
|
||||
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>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Views;
|
||||
public partial class SettingsPage: ContentPage
|
||||
|
|
@ -9,4 +15,56 @@ public partial class SettingsPage: ContentPage
|
|||
{
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ImageMagick;
|
||||
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Api.Helpers;
|
||||
|
|
@ -14,6 +16,17 @@ namespace Yavsc.WebApi.Controllers
|
|||
[Authorize("ApiScope")]
|
||||
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;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
|
|
@ -70,14 +83,93 @@ namespace Yavsc.WebApi.Controllers
|
|||
public async Task<IActionResult> SetAvatar()
|
||||
{
|
||||
var user = await GetUserData(User.GetUserId());
|
||||
if (Request.Form.Files.Count!=1)
|
||||
return new BadRequestResult();
|
||||
if (!Request.Form.Files[0].ContentType.StartsWith("image/png"))
|
||||
return new BadRequestResult();
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
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]);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
return Ok(info);
|
||||
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();
|
||||
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")]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl)
|
||||
? string.Empty
|
||||
: 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)
|
||||
? "/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 uploadUrlBase = "@setAvatarUrl";
|
||||
const statusNode = document.getElementById('avatar-upload-status');
|
||||
const uploadUrl = bearer
|
||||
? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer)
|
||||
: uploadUrlBase;
|
||||
|
||||
function setStatus(message, isError) {
|
||||
if (!statusNode) return;
|
||||
statusNode.textContent = message || '';
|
||||
statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
|
||||
}
|
||||
|
||||
Dropzone.options.postavatar = {
|
||||
maxFilesize: 2, // MB (an avatar)
|
||||
maxFilesize: @maxAvatarSizeMb, // MB
|
||||
acceptedFiles: "@acceptedAvatarMimeTypes",
|
||||
maxFiles: 1,
|
||||
addRemoveLinks: true,
|
||||
autoProcessQueue: true,
|
||||
accept: function(file, done) {
|
||||
if (file.name == "justinbieber.jpg") {
|
||||
done("Naha, you don't.");
|
||||
}
|
||||
else { done(); }
|
||||
},
|
||||
url: uploadUrl,
|
||||
init: function() {
|
||||
this.on('sending', function(file, xhr) {
|
||||
setStatus('Upload en cours...', false);
|
||||
if (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>
|
||||
}
|
||||
<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">
|
||||
<div class="fallback">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue