mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)
This commit is contained in:
@@ -31,8 +31,24 @@ Table Photo {
|
||||
|
||||
Table UserAvatar {
|
||||
UserAvatarID uniqueidentifier [pk, default: `newid()`]
|
||||
UserAccountID uniqueidentifier [not null, unique]
|
||||
UserProfileID uniqueidentifier [not null]
|
||||
PhotoID uniqueidentifier [not null]
|
||||
ValidFrom "datetime2(3)" [not null, default: `sysutcdatetime()`]
|
||||
ValidTo "datetime2(3)" [note: 'NULL = currently active']
|
||||
RowVersion rowversion
|
||||
|
||||
Note: 'Deleted when the user profile is deleted. At most one row per profile has ValidTo = NULL.'
|
||||
|
||||
Indexes {
|
||||
UserProfileID [unique, name: 'UX_UserAvatar_ActiveOne', note: 'filtered: WHERE ValidTo IS NULL']
|
||||
PhotoID [name: 'IX_UserAvatar_Photo']
|
||||
}
|
||||
}
|
||||
|
||||
Table UserProfile {
|
||||
UserProfileID uniqueidentifier [pk, default: `newid()`]
|
||||
Biography "nvarchar(max)" [not null]
|
||||
UserAccountID uniqueidentifier [not null, unique]
|
||||
RowVersion rowversion
|
||||
|
||||
Note: 'Deleted when the user account is deleted'
|
||||
@@ -216,8 +232,9 @@ Table BeerPostComment {
|
||||
}
|
||||
|
||||
Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action]
|
||||
Ref: UserAvatar.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
||||
Ref: UserAvatar.PhotoID > Photo.PhotoID
|
||||
Ref: UserAvatar.PhotoID > Photo.PhotoID [delete: no action]
|
||||
Ref: UserAvatar.UserProfileID > UserProfile.UserProfileID [delete: cascade]
|
||||
Ref: UserProfile.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
||||
Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
||||
Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade]
|
||||
Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action]
|
||||
|
||||
@@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount
|
||||
CONSTRAINT AK_Email
|
||||
UNIQUE (Email)
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||
(
|
||||
UserAvatarID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
RowVersion ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
||||
|
||||
CONSTRAINT FK_UserAvatar_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount (UserAccountID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_UserAvatar_PhotoID
|
||||
FOREIGN KEY (PhotoID)
|
||||
REFERENCES Photo (PhotoID),
|
||||
|
||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
|
||||
CREATE
|
||||
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
ON UserAvatar(UserAccountID);
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE dbo.UserProfile
|
||||
(
|
||||
UserProfileID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
|
||||
|
||||
Biography NVARCHAR(MAX) NOT NULL,
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
RowVersion ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserProfile
|
||||
PRIMARY KEY (UserProfileID),
|
||||
|
||||
CONSTRAINT AK_UserProfile_UserAccountID
|
||||
UNIQUE (UserAccountID),
|
||||
|
||||
CONSTRAINT FK_UserProfile_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount (UserAccountID)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE dbo.UserAvatar
|
||||
(
|
||||
UserAvatarID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||
|
||||
UserProfileID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
ValidFrom DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_UserAvatar_ValidFrom DEFAULT SYSUTCDATETIME(),
|
||||
|
||||
ValidTo DATETIME2(3) NULL, -- NULL = currently active
|
||||
|
||||
RowVersion ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
||||
|
||||
CONSTRAINT FK_UserAvatar_UserProfile
|
||||
FOREIGN KEY (UserProfileID)
|
||||
REFERENCES dbo.UserProfile (UserProfileID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_UserAvatar_Photo
|
||||
FOREIGN KEY (PhotoID)
|
||||
REFERENCES dbo.Photo (PhotoID)
|
||||
ON DELETE NO ACTION
|
||||
ON UPDATE NO ACTION,
|
||||
|
||||
CONSTRAINT CK_UserAvatar_ValidRange
|
||||
CHECK (ValidTo IS NULL OR ValidTo > ValidFrom)
|
||||
);
|
||||
|
||||
-- At most one active avatar per profile
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne
|
||||
ON dbo.UserAvatar (UserProfileID)
|
||||
WHERE ValidTo IS NULL;
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
|
||||
ON dbo.UserAvatar (PhotoID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
|
||||
ON dbo.UserAvatar (UserProfileID)
|
||||
@@ -9,8 +9,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="4.151.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.57.2" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -18,13 +21,12 @@
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
|
||||
/>
|
||||
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Database.Seed.DatabaseHelpers;
|
||||
|
||||
/// <summary>Generates minimal solid-color square avatars for seeded users.</summary>
|
||||
public static class AvatarGenerator
|
||||
{
|
||||
private const int SizePx = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic solid-color PNG avatar, with the color derived from
|
||||
/// <paramref name="userId" />.
|
||||
/// </summary>
|
||||
public static byte[] GeneratePng(Guid userId)
|
||||
{
|
||||
byte[] idBytes = userId.ToByteArray();
|
||||
SKColor color = new(idBytes[0], idBytes[1], idBytes[2]);
|
||||
|
||||
using SKBitmap bitmap = new(SizePx, SizePx);
|
||||
bitmap.Erase(color);
|
||||
|
||||
using SKImage image = SKImage.FromBitmap(bitmap);
|
||||
using SKData data = image.Encode(SKEncodedImageFormat.Png, quality: 100);
|
||||
|
||||
return data.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Features.Auth.Services;
|
||||
|
||||
namespace Database.Seed.DatabaseHelpers;
|
||||
|
||||
public class NoOpTokenService : ITokenService
|
||||
{
|
||||
public string GenerateAccessToken(Guid userId, string username) => string.Empty;
|
||||
|
||||
public string GenerateRefreshToken(Guid userId, string username) => string.Empty;
|
||||
|
||||
public string GenerateConfirmationToken(Guid userId, string username) => string.Empty;
|
||||
|
||||
public Task<ValidatedToken> ValidateRefreshTokenAsync(string token) =>
|
||||
throw new NotSupportedException("Token validation is not supported during seeding.");
|
||||
|
||||
public Task<ValidatedToken> ValidateConfirmationTokenAsync(string token) =>
|
||||
throw new NotSupportedException("Token validation is not supported during seeding.");
|
||||
|
||||
public Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString) =>
|
||||
throw new NotSupportedException("Token validation is not supported during seeding.");
|
||||
}
|
||||
@@ -11,6 +11,8 @@ ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Database.Seed.dll"]
|
||||
|
||||
@@ -3,15 +3,23 @@ using Database.Seed.DatabaseHelpers;
|
||||
using Database.Seed.PipelineData;
|
||||
using Database.Seed.Sqlite;
|
||||
using Domain.Entities;
|
||||
using Features.Auth.DependencyInjection;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Commands.Authentication.RegisterUser;
|
||||
using Features.Auth.Commands.Profile.UpdateBiography;
|
||||
using Features.Auth.Commands.Profile.UploadAvatar;
|
||||
using Features.Users.DependencyInjection;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
using Features.Breweries.Commands.CreateBrewery;
|
||||
using Features.Breweries.DependencyInjection;
|
||||
using Features.Breweries.Repository;
|
||||
using Features.ImageUploads.Commands.UploadPhoto;
|
||||
using Features.ImageUploads.DependencyInjection;
|
||||
using Features.Locations.Commands.GetOrCreateCity;
|
||||
using Features.Locations.DependencyInjection;
|
||||
using Features.Locations.Dtos;
|
||||
using Features.Locations.Repository;
|
||||
using idunno.Password;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Infrastructure.FileUpload;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
@@ -24,20 +32,24 @@ static async Task<int> RunAsync()
|
||||
{
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
|
||||
|
||||
ServiceCollection services = [];
|
||||
services.AddSingleton(configuration);
|
||||
services.AddDatabaseConnection();
|
||||
services.AddFeaturesBreweries();
|
||||
services.AddFeaturesLocations();
|
||||
services.AddFeaturesAuth();
|
||||
IServiceCollection services = new ServiceCollection()
|
||||
.AddSingleton(configuration)
|
||||
.AddDatabaseConnection()
|
||||
.AddFeaturesBreweries()
|
||||
.AddFeaturesLocations()
|
||||
.AddFeaturesUsers()
|
||||
.AddFeaturesPhotoUpload()
|
||||
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
|
||||
.AddScoped<ITokenService, NoOpTokenService>()
|
||||
.AddMediatR(cfg =>
|
||||
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
|
||||
.RegisterServicesFromAssemblyContaining<GetOrCreateCityCommand>()
|
||||
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
|
||||
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
|
||||
);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IBreweryRepository breweryRepository = provider.GetRequiredService<IBreweryRepository>();
|
||||
ILocationRepository locationRepository = provider.GetRequiredService<ILocationRepository>();
|
||||
UserManager<ApplicationUser> userManager = provider.GetRequiredService<
|
||||
UserManager<ApplicationUser>
|
||||
>();
|
||||
IMediator mediator = provider.GetRequiredService<IMediator>();
|
||||
|
||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
|
||||
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
|
||||
@@ -46,7 +58,7 @@ static async Task<int> RunAsync()
|
||||
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
|
||||
|
||||
SeedData seedData = null!;
|
||||
IReadOnlyList<Guid> postedByIds = [];
|
||||
IReadOnlyList<Guid> userIds = [];
|
||||
|
||||
await AnsiConsole
|
||||
.Status()
|
||||
@@ -87,11 +99,24 @@ static async Task<int> RunAsync()
|
||||
"Loading user data into target database...",
|
||||
async ctx =>
|
||||
{
|
||||
postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users);
|
||||
userIds = await LoadUsersIntoDatabaseAsync(mediator, seedData.Users);
|
||||
ctx.Status("User data loaded into target database.");
|
||||
}
|
||||
);
|
||||
|
||||
await AnsiConsole
|
||||
.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("green"))
|
||||
.StartAsync(
|
||||
"Loading avatars into target database...",
|
||||
async ctx =>
|
||||
{
|
||||
await LoadAvatarsIntoDatabaseAsync(mediator, userIds, seedData.Users, ctx);
|
||||
ctx.Status("Avatar data loaded into target database.");
|
||||
}
|
||||
);
|
||||
|
||||
await AnsiConsole
|
||||
.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
@@ -101,10 +126,9 @@ static async Task<int> RunAsync()
|
||||
async ctx =>
|
||||
{
|
||||
await LoadBreweriesIntoDatabaseAsync(
|
||||
breweryRepository,
|
||||
locationRepository,
|
||||
mediator,
|
||||
seedData.Breweries,
|
||||
postedByIds,
|
||||
userIds,
|
||||
ctx
|
||||
);
|
||||
ctx.Status("Brewery data loaded into target database.");
|
||||
@@ -122,7 +146,7 @@ static async Task<int> RunAsync()
|
||||
}
|
||||
|
||||
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IMediator mediator,
|
||||
IReadOnlyList<UserRecord> users
|
||||
)
|
||||
{
|
||||
@@ -130,37 +154,57 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||
|
||||
foreach (UserRecord userRecord in users)
|
||||
{
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
FirstName = userRecord.User.FirstName,
|
||||
LastName = userRecord.User.LastName,
|
||||
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
|
||||
UserName = userRecord.User.Username,
|
||||
Email = userRecord.Email,
|
||||
};
|
||||
|
||||
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
|
||||
// digit characters (0-9), which idunno.Password rejects outright.
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
user,
|
||||
PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true)
|
||||
string password = PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true);
|
||||
|
||||
RegistrationPayload registration = await mediator.Send(
|
||||
new RegisterUserCommand(
|
||||
userRecord.User.Username,
|
||||
userRecord.User.FirstName,
|
||||
userRecord.User.LastName,
|
||||
userRecord.Email,
|
||||
DateTime.Parse(userRecord.DateOfBirth),
|
||||
password
|
||||
)
|
||||
);
|
||||
|
||||
if (!result.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to seed user '{userRecord.User.Username}': "
|
||||
+ string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
|
||||
userAccountIds.Add(user.Id);
|
||||
userAccountIds.Add(registration.UserAccountId);
|
||||
}
|
||||
|
||||
return userAccountIds;
|
||||
}
|
||||
|
||||
static async Task LoadAvatarsIntoDatabaseAsync(
|
||||
IMediator mediator,
|
||||
IReadOnlyList<Guid> userIds,
|
||||
IReadOnlyList<UserRecord> users,
|
||||
StatusContext ctx
|
||||
)
|
||||
{
|
||||
for (int i = 0; i < userIds.Count; i++)
|
||||
{
|
||||
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
|
||||
|
||||
Guid userId = userIds[i];
|
||||
|
||||
await mediator.Send(new UpdateBiographyCommand(userId, users[i].User.Bio));
|
||||
|
||||
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
|
||||
|
||||
await using MemoryStream stream = new(avatarPng);
|
||||
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "image/png",
|
||||
};
|
||||
|
||||
await mediator.Send(new UploadAvatarCommand(userId, file));
|
||||
}
|
||||
}
|
||||
|
||||
static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
IBreweryRepository breweryRepository,
|
||||
ILocationRepository locationRepository,
|
||||
IMediator mediator,
|
||||
IReadOnlyList<BreweryRecord> breweries,
|
||||
IReadOnlyList<Guid> posterUserIds,
|
||||
StatusContext ctx
|
||||
@@ -175,35 +219,34 @@ static async Task LoadBreweriesIntoDatabaseAsync(
|
||||
|
||||
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
|
||||
|
||||
Guid cityId = await locationRepository.GetOrCreateCityIdAsync(
|
||||
new CityLocation(
|
||||
breweryRecord.Address.City.CityName,
|
||||
breweryRecord.Address.City.StateProvince,
|
||||
breweryRecord.Address.City.Iso31662,
|
||||
breweryRecord.Address.City.Country,
|
||||
breweryRecord.Address.City.Iso31661
|
||||
Guid cityId = await mediator.Send(
|
||||
new GetOrCreateCityCommand(
|
||||
new CityLocation(
|
||||
breweryRecord.Address.City.CityName,
|
||||
breweryRecord.Address.City.StateProvince,
|
||||
breweryRecord.Address.City.Iso31662,
|
||||
breweryRecord.Address.City.Country,
|
||||
breweryRecord.Address.City.Iso31661
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await breweryRepository.CreateAsync(
|
||||
new BreweryPost
|
||||
{
|
||||
BreweryPostId = Guid.NewGuid(),
|
||||
BreweryName = breweryRecord.Brewery.NameEn,
|
||||
Description = breweryRecord.Brewery.DescriptionEn,
|
||||
PostedById = posterUserIds[i % posterUserIds.Count],
|
||||
Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = Guid.NewGuid(),
|
||||
CityId = cityId,
|
||||
AddressLine1 = breweryRecord.Address.AddressLine1,
|
||||
PostalCode = breweryRecord.Address.PostalCode,
|
||||
Coordinates = new CoordinateData(
|
||||
await mediator.Send(
|
||||
new CreateBreweryCommand(
|
||||
posterUserIds[i % posterUserIds.Count],
|
||||
breweryRecord.Brewery.NameEn,
|
||||
breweryRecord.Brewery.DescriptionEn,
|
||||
new CreateBreweryLocation(
|
||||
cityId,
|
||||
breweryRecord.Address.AddressLine1,
|
||||
null,
|
||||
breweryRecord.Address.PostalCode,
|
||||
new CoordinateData(
|
||||
breweryRecord.Address.Latitude,
|
||||
breweryRecord.Address.Longitude
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user