From 5f11c2445b1b59df3a1a574aa53a453d7ef4ce85 Mon Sep 17 00:00:00 2001 From: Demis Bellot Date: Sun, 17 Mar 2024 03:08:07 +0800 Subject: [PATCH] Add support for CDN backed User Avatars --- .../BackgroundMqServices.cs | 21 + MyApp.ServiceInterface/CdnServices.cs | 31 ++ MyApp.ServiceInterface/Data/Urls.cs | 10 + MyApp.ServiceInterface/EmailServices.cs | 2 +- MyApp.ServiceInterface/ImageUtils.cs | 57 +++ .../MyApp.ServiceInterface.csproj | 1 + MyApp.ServiceInterface/UserServices.cs | 85 ++++ MyApp.ServiceModel/DiskTasks.cs | 39 ++ MyApp.ServiceModel/Tag.cs | 7 + MyApp.ServiceModel/User.cs | 20 + .../Account/Pages/Manage/Index.razor | 36 +- MyApp/Components/App.razor | 1 + MyApp/Components/Shared/Header.razor | 2 +- MyApp/Configure.AppHost.cs | 19 +- MyApp/Configure.Mq.cs | 1 + MyApp/Program.cs | 4 +- MyApp/wwwroot/mjs/dtos.mjs | 393 ++++++++---------- .../wwwroot/pages/components/EditProfile.mjs | 36 ++ 18 files changed, 513 insertions(+), 252 deletions(-) create mode 100644 MyApp.ServiceInterface/BackgroundMqServices.cs create mode 100644 MyApp.ServiceInterface/CdnServices.cs create mode 100644 MyApp.ServiceInterface/Data/Urls.cs create mode 100644 MyApp.ServiceInterface/ImageUtils.cs create mode 100644 MyApp.ServiceInterface/UserServices.cs create mode 100644 MyApp.ServiceModel/DiskTasks.cs create mode 100644 MyApp.ServiceModel/Tag.cs create mode 100644 MyApp.ServiceModel/User.cs create mode 100644 MyApp/wwwroot/pages/components/EditProfile.mjs diff --git a/MyApp.ServiceInterface/BackgroundMqServices.cs b/MyApp.ServiceInterface/BackgroundMqServices.cs new file mode 100644 index 0000000..c9e14f4 --- /dev/null +++ b/MyApp.ServiceInterface/BackgroundMqServices.cs @@ -0,0 +1,21 @@ +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.IO; + +namespace MyApp.ServiceInterface; + +public class BackgroundMqServices(R2VirtualFiles r2) : Service +{ + public async Task Any(DiskTasks request) + { + if (request.SaveFile != null) + { + await r2.WriteFileAsync(request.SaveFile.FilePath, request.SaveFile.Stream); + } + + if (request.CdnDeleteFiles != null) + { + r2.DeleteFiles(request.CdnDeleteFiles); + } + } +} \ No newline at end of file diff --git a/MyApp.ServiceInterface/CdnServices.cs b/MyApp.ServiceInterface/CdnServices.cs new file mode 100644 index 0000000..2cf700e --- /dev/null +++ b/MyApp.ServiceInterface/CdnServices.cs @@ -0,0 +1,31 @@ +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.IO; + +namespace MyApp.ServiceInterface; + +public class CdnServices(R2VirtualFiles r2) : Service +{ + public object Any(DeleteCdnFilesMq request) + { + var msg = new DiskTasks + { + CdnDeleteFiles = request.Files + }; + PublishMessage(msg); + return msg; + } + + public void Any(DeleteCdnFile request) + { + r2.DeleteFile(request.File); + } + + public object Any(GetCdnFile request) + { + var file = r2.GetFile(request.File); + if (file == null) + throw new FileNotFoundException(request.File); + return new HttpResult(file); + } +} diff --git a/MyApp.ServiceInterface/Data/Urls.cs b/MyApp.ServiceInterface/Data/Urls.cs new file mode 100644 index 0000000..3078a68 --- /dev/null +++ b/MyApp.ServiceInterface/Data/Urls.cs @@ -0,0 +1,10 @@ +using ServiceStack; + +namespace MyApp.Data; + +public static class Urls +{ + public static string GetAvatarUrl(this string? userName) => userName != null + ? $"/avatar/{userName}" + : Svg.ToDataUri(Svg.GetImage(Svg.Icons.Users)); +} diff --git a/MyApp.ServiceInterface/EmailServices.cs b/MyApp.ServiceInterface/EmailServices.cs index 73c00c9..5e84e54 100644 --- a/MyApp.ServiceInterface/EmailServices.cs +++ b/MyApp.ServiceInterface/EmailServices.cs @@ -50,7 +50,7 @@ public class SmtpConfig /// public class EmailServices(SmtpConfig config, ILogger log) // TODO: Uncomment to enable sending emails with SMTP - // : Service + : Service { public object Any(SendEmail request) { diff --git a/MyApp.ServiceInterface/ImageUtils.cs b/MyApp.ServiceInterface/ImageUtils.cs new file mode 100644 index 0000000..e2a13fe --- /dev/null +++ b/MyApp.ServiceInterface/ImageUtils.cs @@ -0,0 +1,57 @@ +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.Host; +using ServiceStack.Web; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; + +namespace MyApp.ServiceInterface; + +public static class ImageUtils +{ + public const int MaxAvatarSize = 1024 * 1024; + + public static async Task CropAndResizeAsync(Stream inStream, int width, int height, IImageFormat format) + { + var outStream = new MemoryStream(); + using (var image = await Image.LoadAsync(inStream)) + { + var clone = image.Clone(context => context + .Resize(new ResizeOptions { + Mode = ResizeMode.Crop, + Size = new Size(width, height), + })); + await clone.SaveAsync(outStream, format); + } + outStream.Position = 0; + return outStream; + } + + public static async Task TransformAvatarAsync(FilesUploadContext ctx) + { + var originalMs = await ctx.File.InputStream.CopyToNewMemoryStreamAsync(); + + var resizedMs = await CropAndResizeAsync(originalMs, 128, 128, PngFormat.Instance); + + // Offload persistence of original image to background task + originalMs.Position = 0; + using var mqClient = HostContext.AppHost.GetMessageProducer(ctx.Request); + mqClient.Publish(new DiskTasks + { + SaveFile = new() + { + FilePath = ctx.Location.ResolvePath(ctx), + Stream = originalMs, + } + }); + + return new HttpFile(ctx.File) + { + FileName = $"{ctx.FileName.LastLeftPart('.')}_128.{ctx.File.FileName.LastRightPart('.')}", + ContentLength = resizedMs.Length, + InputStream = resizedMs, + }; + } +} diff --git a/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj b/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj index 59ecdfa..3d97ef9 100644 --- a/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj +++ b/MyApp.ServiceInterface/MyApp.ServiceInterface.csproj @@ -12,6 +12,7 @@ + diff --git a/MyApp.ServiceInterface/UserServices.cs b/MyApp.ServiceInterface/UserServices.cs new file mode 100644 index 0000000..7064d70 --- /dev/null +++ b/MyApp.ServiceInterface/UserServices.cs @@ -0,0 +1,85 @@ +using MyApp.Data; +using ServiceStack; +using MyApp.ServiceModel; +using ServiceStack.IO; +using ServiceStack.OrmLite; +using SixLabors.ImageSharp.Formats.Png; + +namespace MyApp.ServiceInterface; + +public class UserServices(R2VirtualFiles r2) : Service +{ + private const string AppData = "/App_Data"; + + public async Task Any(UpdateUserProfile request) + { + var userName = Request.GetClaimsPrincipal().Identity!.Name!; + var file = base.Request!.Files.FirstOrDefault(); + + if (file != null) + { + var userProfileDir = $"/profiles/{userName[..2]}/{userName}"; + var origPath = userProfileDir.CombineWith(file.FileName); + var fileName = $"{file.FileName.LastLeftPart('.')}_128.{file.FileName.LastRightPart('.')}"; + var profilePath = userProfileDir.CombineWith(fileName); + var originalMs = await file.InputStream.CopyToNewMemoryStreamAsync(); + var resizedMs = await ImageUtils.CropAndResizeAsync(originalMs, 128, 128, PngFormat.Instance); + + await VirtualFiles.WriteFileAsync(AppData.CombineWith(origPath), originalMs); + await VirtualFiles.WriteFileAsync(AppData.CombineWith(profilePath), resizedMs); + + await Db.UpdateOnlyAsync(() => new ApplicationUser { + ProfilePath = profilePath, + }, x => x.UserName == userName); + + PublishMessage(new DiskTasks { + SaveFile = new() { + FilePath = origPath, + Stream = originalMs, + } + }); + PublishMessage(new DiskTasks { + SaveFile = new() { + FilePath = profilePath, + Stream = resizedMs, + } + }); + } + + return new UpdateUserProfileResponse(); + } + + public async Task Any(GetUserAvatar request) + { + if (!string.IsNullOrEmpty(request.UserName)) + { + var profilePath = Db.Scalar(Db.From() + .Where(x => x.UserName == request.UserName) + .Select(x => x.ProfilePath)); + if (!string.IsNullOrEmpty(profilePath)) + { + if (profilePath.StartsWith("data:")) + { + return new HttpResult(profilePath, MimeTypes.ImageSvg); + } + if (profilePath.StartsWith('/')) + { + var localProfilePath = AppData.CombineWith(profilePath); + var file = VirtualFiles.GetFile(localProfilePath); + if (file != null) + { + return new HttpResult(file, MimeTypes.GetMimeType(file.Extension)); + } + file = r2.GetFile(profilePath); + var bytes = file != null ? await file.ReadAllBytesAsync() : null; + if (bytes is { Length: > 0 }) + { + await VirtualFiles.WriteFileAsync(localProfilePath, bytes); + return new HttpResult(bytes, MimeTypes.GetMimeType(file!.Extension)); + } + } + } + } + return new HttpResult(Svg.GetImage(Svg.Icons.Users), MimeTypes.ImageSvg); + } +} diff --git a/MyApp.ServiceModel/DiskTasks.cs b/MyApp.ServiceModel/DiskTasks.cs new file mode 100644 index 0000000..70d55e0 --- /dev/null +++ b/MyApp.ServiceModel/DiskTasks.cs @@ -0,0 +1,39 @@ +using ServiceStack; +using ServiceStack.DataAnnotations; + +namespace MyApp.ServiceModel; + +[Tag(Tag.Tasks)] +[ExcludeMetadata] +[Restrict(InternalOnly = true)] +public class DiskTasks : IReturnVoid +{ + public SaveFile? SaveFile { get; set; } + public List? CdnDeleteFiles { get; set; } +} +public class SaveFile +{ + public string FilePath { get; set; } + public Stream Stream { get; set; } +} + +[Tag(Tag.Tasks)] +[ValidateHasRole(Roles.Moderator)] +public class DeleteCdnFilesMq +{ + public List Files { get; set; } +} + +[Tag(Tag.Tasks)] +[ValidateHasRole(Roles.Moderator)] +public class GetCdnFile +{ + public string File { get; set; } +} + +[Tag(Tag.Tasks)] +[ValidateHasRole(Roles.Moderator)] +public class DeleteCdnFile : IReturnVoid +{ + public string File { get; set; } +} diff --git a/MyApp.ServiceModel/Tag.cs b/MyApp.ServiceModel/Tag.cs new file mode 100644 index 0000000..3871d14 --- /dev/null +++ b/MyApp.ServiceModel/Tag.cs @@ -0,0 +1,7 @@ +namespace MyApp.ServiceModel; + +public static class Tag +{ + public const string Tasks = nameof(Tasks); + public const string User = nameof(User); +} \ No newline at end of file diff --git a/MyApp.ServiceModel/User.cs b/MyApp.ServiceModel/User.cs new file mode 100644 index 0000000..0042e80 --- /dev/null +++ b/MyApp.ServiceModel/User.cs @@ -0,0 +1,20 @@ +using ServiceStack; + +namespace MyApp.ServiceModel; + +[Tag(Tag.User)] +[ValidateIsAuthenticated] +public class UpdateUserProfile : IPost, IReturn +{ +} + +public class UpdateUserProfileResponse +{ + public ResponseStatus ResponseStatus { get; set; } +} + +[Route("/avatar/{UserName}", "GET")] +public class GetUserAvatar : IGet, IReturn +{ + public string UserName { get; set; } +} diff --git a/MyApp/Components/Account/Pages/Manage/Index.razor b/MyApp/Components/Account/Pages/Manage/Index.razor index 961e082..eabb74c 100644 --- a/MyApp/Components/Account/Pages/Manage/Index.razor +++ b/MyApp/Components/Account/Pages/Manage/Index.razor @@ -17,28 +17,23 @@
- - - -
-
- -
- -
-
-
- -
- + class="bg-white dark:bg-black"> + +
+ + + +
+
+ +
+ +
- -
-
- Save
+ +
@@ -83,5 +78,8 @@ [Phone] [Display(Name = "Phone number")] public string? PhoneNumber { get; set; } + + [Display(Name = "Avatar")] + public string? ProfileUrl { get; set; } } } diff --git a/MyApp/Components/App.razor b/MyApp/Components/App.razor index 4005427..a425ee3 100644 --- a/MyApp/Components/App.razor +++ b/MyApp/Components/App.razor @@ -13,6 +13,7 @@ @BlazorHtml.ImportMap(new() { ["app.mjs"] = ("/mjs/app.mjs", "/mjs/app.mjs"), + ["dtos.mjs"] = ("/mjs/dtos.mjs", "/mjs/dtos.mjs"), ["vue"] = ("/lib/mjs/vue.mjs", "/lib/mjs/vue.min.mjs"), ["@servicestack/client"] = ("/lib/mjs/servicestack-client.mjs", "/lib/mjs/servicestack-client.min.mjs"), ["@servicestack/vue"] = ("/lib/mjs/servicestack-vue.mjs", "/lib/mjs/servicestack-vue.min.mjs"), diff --git a/MyApp/Components/Shared/Header.razor b/MyApp/Components/Shared/Header.razor index e1e1c60..7dc122c 100644 --- a/MyApp/Components/Shared/Header.razor +++ b/MyApp/Components/Shared/Header.razor @@ -45,7 +45,7 @@