Skip to content

Commit

Permalink
Add support for CDN backed User Avatars
Browse files Browse the repository at this point in the history
  • Loading branch information
mythz committed Mar 16, 2024
1 parent 49ec9ee commit 5f11c24
Show file tree
Hide file tree
Showing 18 changed files with 513 additions and 252 deletions.
21 changes: 21 additions & 0 deletions MyApp.ServiceInterface/BackgroundMqServices.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
31 changes: 31 additions & 0 deletions MyApp.ServiceInterface/CdnServices.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions MyApp.ServiceInterface/Data/Urls.cs
Original file line number Diff line number Diff line change
@@ -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));
}
2 changes: 1 addition & 1 deletion MyApp.ServiceInterface/EmailServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class SmtpConfig
/// </summary>
public class EmailServices(SmtpConfig config, ILogger<EmailServices> log)
// TODO: Uncomment to enable sending emails with SMTP
// : Service
: Service
{
public object Any(SendEmail request)
{
Expand Down
57 changes: 57 additions & 0 deletions MyApp.ServiceInterface/ImageUtils.cs
Original file line number Diff line number Diff line change
@@ -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<MemoryStream> 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<IHttpFile?> 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,
};
}
}
1 change: 1 addition & 0 deletions MyApp.ServiceInterface/MyApp.ServiceInterface.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="ServiceStack" Version="8.*" />
<PackageReference Include="ServiceStack.Ormlite" Version="8.*" />
<PackageReference Include="ServiceStack.Aws" Version="8.*" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
85 changes: 85 additions & 0 deletions MyApp.ServiceInterface/UserServices.cs
Original file line number Diff line number Diff line change
@@ -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<object> 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<object> Any(GetUserAvatar request)
{
if (!string.IsNullOrEmpty(request.UserName))
{
var profilePath = Db.Scalar<string>(Db.From<ApplicationUser>()
.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);
}
}
39 changes: 39 additions & 0 deletions MyApp.ServiceModel/DiskTasks.cs
Original file line number Diff line number Diff line change
@@ -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<string>? CdnDeleteFiles { get; set; }
}
public class SaveFile
{
public string FilePath { get; set; }

Check warning on line 16 in MyApp.ServiceModel/DiskTasks.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'FilePath' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
public Stream Stream { get; set; }

Check warning on line 17 in MyApp.ServiceModel/DiskTasks.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Stream' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}

[Tag(Tag.Tasks)]
[ValidateHasRole(Roles.Moderator)]
public class DeleteCdnFilesMq
{
public List<string> Files { get; set; }

Check warning on line 24 in MyApp.ServiceModel/DiskTasks.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Files' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}

[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; }
}
7 changes: 7 additions & 0 deletions MyApp.ServiceModel/Tag.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace MyApp.ServiceModel;

public static class Tag
{
public const string Tasks = nameof(Tasks);
public const string User = nameof(User);
}
20 changes: 20 additions & 0 deletions MyApp.ServiceModel/User.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using ServiceStack;

namespace MyApp.ServiceModel;

[Tag(Tag.User)]
[ValidateIsAuthenticated]
public class UpdateUserProfile : IPost, IReturn<UpdateUserProfileResponse>
{
}

public class UpdateUserProfileResponse
{
public ResponseStatus ResponseStatus { get; set; }

Check warning on line 13 in MyApp.ServiceModel/User.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'ResponseStatus' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Check warning on line 13 in MyApp.ServiceModel/User.cs

View workflow job for this annotation

GitHub Actions / push_to_registry

Non-nullable property 'ResponseStatus' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}

[Route("/avatar/{UserName}", "GET")]
public class GetUserAvatar : IGet, IReturn<byte[]>
{
public string UserName { get; set; }

Check warning on line 19 in MyApp.ServiceModel/User.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'UserName' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Check warning on line 19 in MyApp.ServiceModel/User.cs

View workflow job for this annotation

GitHub Actions / push_to_registry

Non-nullable property 'UserName' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.
}
36 changes: 17 additions & 19 deletions MyApp/Components/Account/Pages/Manage/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,23 @@
<div class="max-w-xl">
<div class="shadow overflow-hidden sm:rounded-md">
<EditForm id="profile-form" Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" method="post"
class="px-4 bg-white dark:bg-black sm:p-6">
<DataAnnotationsValidator />
<ValidationSummary class="mb-3 text-danger text-center font-semibold" />

<div class="flex flex-col gap-y-4">
<div>
<label for="username" class="@TextInput.LabelClasses">Username</label>
<div class="mt-1 relative rounded-md shadow-sm">
<input id="username" type="text" value="@username" class="@TextInput.InputClasses" placeholder="Please choose your username." disabled />
</div>
</div>
<div>
<label for="phone-number" class="@TextInput.LabelClasses">Phone number</label>
<div class="mt-1 relative rounded-md shadow-sm">
<InputText id="phone-number" type="text" @bind-Value="Input.PhoneNumber" class="@TextInput.InputClasses" placeholder="Please enter your phone number." />
class="bg-white dark:bg-black">

<div class="px-4 sm:p-6">
<DataAnnotationsValidator/>
<ValidationSummary class="mb-3 text-danger text-center font-semibold"/>

<div class="flex flex-col gap-y-4">
<div>
<label for="username" class="@TextInput.LabelClasses">Username</label>
<div class="mt-1 relative rounded-md shadow-sm">
<input id="username" type="text" value="@username" class="@TextInput.InputClasses" placeholder="Please choose your username." disabled/>
</div>
</div>
<ValidationMessage For="() => Input.PhoneNumber" class="mt-2 text-danger text-sm" />
</div>
<div>
<PrimaryButton id="update-profile-button" type="submit">Save</PrimaryButton>
</div>
</div>

<div data-component="pages/components/EditProfile.mjs"></div>
</EditForm>
</div>
</div>
Expand Down Expand Up @@ -83,5 +78,8 @@
[Phone]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }

[Display(Name = "Avatar")]
public string? ProfileUrl { get; set; }
}
}
1 change: 1 addition & 0 deletions MyApp/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion MyApp/Components/Shared/Header.razor
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<div>
<NavLink href="Account/Manage"
class="max-w-xs bg-white dark:bg-black rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 lg:p-2 lg:rounded-md lg:hover:bg-gray-50 dark:lg:hover:bg-gray-900 dark:ring-offset-black" id="user-menu-button" aria-expanded="false" aria-haspopup="true">
<img class="h-8 w-8 rounded-full" src="@User.GetPicture()" alt="">
<img id="user-avatar" class="h-8 w-8 rounded-full" src="/avatar/@User.GetDisplayName()" alt="">
<span class="hidden ml-3 text-gray-700 dark:text-gray-300 text-sm font-medium lg:block">
<span class="sr-only">Open user menu for </span>
@User.GetDisplayName()
Expand Down
Loading

0 comments on commit 5f11c24

Please sign in to comment.