Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public ValueTask<ClusterConfig> ConfigureClusterAsync(ClusterConfig cluster, Can
{
"account-management-api" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_API_URL"),
"account-management-static" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_API_URL"),
"avatars-storage" => ReplaceDestinationAddress(cluster, "AVATARS_STORAGE_URL"),
"account-management-storage" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_STORAGE_URL"),
"back-office-api" => ReplaceDestinationAddress(cluster, "BACK_OFFICE_API_URL"),
"back-office-static" => ReplaceDestinationAddress(cluster, "BACK_OFFICE_API_URL"),
_ => throw new InvalidOperationException($"Unknown Cluster ID {cluster.ClusterId}.")
Expand Down
2 changes: 1 addition & 1 deletion application/AppGateway/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@
);
}

builder.AddNamedBlobStorages(("avatars-storage", "AVATARS_STORAGE_URL"));
builder.AddNamedBlobStorages(("account-management-storage", "ACCOUNT_MANAGEMENT_STORAGE_URL"));

builder.WebHost.UseKestrel(option => option.AddServerHeader = false);

builder.Services.AddHttpClient(
"AccountManagement",
client => { client.BaseAddress = new Uri(Environment.GetEnvironmentVariable("ACCOUNT_MANAGEMENT_API_URL") ?? "https://localhost:9100"); }

Check warning on line 47 in application/AppGateway/Program.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)

Check warning on line 47 in application/AppGateway/Program.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)

Check warning on line 47 in application/AppGateway/Program.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)

Check warning on line 47 in application/AppGateway/Program.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)
);

builder.Services
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public class ManagedIdentityTransform(TokenCredential credential)
{
protected override string? GetValue(RequestTransformContext context)
{
if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars", StringComparison.OrdinalIgnoreCase))
if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars", StringComparison.OrdinalIgnoreCase) &&
!context.HttpContext.Request.Path.StartsWithSegments("/logos", StringComparison.OrdinalIgnoreCase))
{
return null;
}
Expand All @@ -23,6 +24,9 @@ public class ApiVersionHeaderTransform() : RequestHeaderTransform("x-ms-version"
{
protected override string? GetValue(RequestTransformContext context)
{
return !context.HttpContext.Request.Path.StartsWithSegments("/avatars") ? null : "2023-11-03";
return !context.HttpContext.Request.Path.StartsWithSegments("/avatars") &&
!context.HttpContext.Request.Path.StartsWithSegments("/logos")
? null
: "2023-11-03";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,26 @@

namespace PlatformPlatform.AppGateway.Transformations;

public class SharedAccessSignatureRequestTransform([FromKeyedServices("avatars-storage")] BlobStorageClient blobStorageClient)
public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] BlobStorageClient accountManagementBlobStorageClient)
: RequestTransform
{
public override ValueTask ApplyAsync(RequestTransformContext context)
{
if (!context.Path.StartsWithSegments("/avatars")) return ValueTask.CompletedTask;
string containerName;
if (context.Path.StartsWithSegments("/avatars"))
{
containerName = "avatars";
}
else if (context.Path.StartsWithSegments("/logos"))
{
containerName = "logos";
}
else
{
return ValueTask.CompletedTask;
}

var sharedAccessSignature = blobStorageClient.GetSharedAccessSignature("avatars", TimeSpan.FromMinutes(10));
var sharedAccessSignature = accountManagementBlobStorageClient.GetSharedAccessSignature(containerName, TimeSpan.FromMinutes(10));
context.HttpContext.Request.QueryString = new QueryString(sharedAccessSignature);

return ValueTask.CompletedTask;
Expand Down
16 changes: 14 additions & 2 deletions application/AppGateway/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}
},
"avatars": {
"ClusterId": "avatars-storage",
"ClusterId": "account-management-storage",
"Match": {
"Path": "/avatars/{**catch-all}"
},
Expand All @@ -79,6 +79,18 @@
}
]
},
"logos": {
"ClusterId": "account-management-storage",
"Match": {
"Path": "/logos/{**catch-all}"
},
"Transforms": [
{
"ResponseHeader": "Cache-Control",
"Set": "public, max-age=2592000, immutable"
}
]
},
"account-management-api": {
"ClusterId": "account-management-api",
"Match": {
Expand Down Expand Up @@ -205,7 +217,7 @@
}
}
},
"avatars-storage": {
"account-management-storage": {
"Destinations": {
"destination": {
"Address": "http://127.0.0.1:10000/devstoreaccount1"
Expand Down
1 change: 1 addition & 0 deletions application/AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
.WithUrlForEndpoint("http", u => u.DisplayText = "Read mail here");

CreateBlobContainer("avatars");
CreateBlobContainer("logos");

var frontendBuild = builder
.AddNpmApp("frontend-build", "../")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
=> (await mediator.Send(command)).AddRefreshAuthenticationTokens()
);

group.MapPost("/current/update-logo", async Task<ApiResult> (IFormFile file, IMediator mediator)
=> await mediator.Send(new UpdateTenantLogoCommand(file.OpenReadStream(), file.ContentType))
).DisableAntiforgery();

group.MapDelete("/current/remove-logo", async Task<ApiResult> (IMediator mediator)
=> await mediator.Send(new RemoveTenantLogoCommand())
);

routes.MapDelete("/internal-api/account-management/tenants/{id}", async Task<ApiResult> (TenantId id, IMediator mediator)
=> await mediator.Send(new DeleteTenantCommand(id))
);
Expand Down
5 changes: 4 additions & 1 deletion application/account-management/Core/Configuration.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.AccountManagement.Features.Tenants;
using PlatformPlatform.AccountManagement.Features.Users.Shared;
using PlatformPlatform.AccountManagement.Integrations.Gravatar;
using PlatformPlatform.SharedKernel.Configuration;
Expand All @@ -16,18 +17,20 @@
// Infrastructure is configured separately from other Infrastructure services to allow mocking in tests
return builder
.AddSharedInfrastructure<AccountManagementDbContext>("account-management-database")
.AddNamedBlobStorages(("avatars-storage", "BLOB_STORAGE_URL"));
.AddNamedBlobStorages(("account-management-storage", "BLOB_STORAGE_URL"));
}

public static IServiceCollection AddAccountManagementServices(this IServiceCollection services)
{
services.AddHttpClient<GravatarClient>(client =>
{
client.BaseAddress = new Uri("https://gravatar.com/");

Check warning on line 27 in application/account-management/Core/Configuration.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)

Check warning on line 27 in application/account-management/Core/Configuration.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)

Check warning on line 27 in application/account-management/Core/Configuration.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)

Check warning on line 27 in application/account-management/Core/Configuration.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)
client.Timeout = TimeSpan.FromSeconds(5);
}
);

TenantMapsterConfig.Configure();

return services
.AddSharedServices<AccountManagementDbContext>(Assembly)
.AddScoped<AvatarUpdater>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

namespace PlatformPlatform.AccountManagement.Database.Migrations;

[DbContext(typeof(AccountManagementDbContext))]
[Migration("20250804001944_AddTenantLogo")]
public sealed class AddTenantLogo : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Logo",
table: "Tenants",
type: "varchar(150)",
nullable: false,
defaultValue: "{}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ public sealed class TenantCreated(TenantId tenantId, TenantState state)
public sealed class TenantDeleted(TenantId tenantId, TenantState tenantState, int usersDeleted)
: TelemetryEvent(("tenant_id", tenantId), ("tenant_state", tenantState), ("users_deleted", usersDeleted));

public sealed class TenantLogoRemoved
: TelemetryEvent;

public sealed class TenantLogoUpdated(string contentType, long size)
: TelemetryEvent(("content_type", contentType), ("size", size));

public sealed class TenantUpdated
: TelemetryEvent;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using JetBrains.Annotations;
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.ExecutionContext;
using PlatformPlatform.SharedKernel.Telemetry;

namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands;

[PublicAPI]
public sealed record RemoveTenantLogoCommand : ICommand, IRequest<Result>;

public sealed class RemoveTenantLogoHandler(
ITenantRepository tenantRepository,
IExecutionContext executionContext,
ITelemetryEventsCollector events
)
: IRequestHandler<RemoveTenantLogoCommand, Result>
{
public async Task<Result> Handle(RemoveTenantLogoCommand command, CancellationToken cancellationToken)
{
if (executionContext.UserInfo.Role != UserRole.Owner.ToString())
{
return Result.Forbidden("Only owners are allowed to remove tenant logo.");
}

var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken);

tenant.RemoveLogo();
tenantRepository.Update(tenant);

events.CollectEvent(new TenantLogoRemoved());

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Security.Cryptography;
using FluentValidation;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
using PlatformPlatform.AccountManagement.Features.Users.Domain;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.ExecutionContext;
using PlatformPlatform.SharedKernel.Integrations.BlobStorage;
using PlatformPlatform.SharedKernel.Telemetry;

namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands;

[PublicAPI]
public sealed record UpdateTenantLogoCommand(Stream FileStream, string ContentType) : ICommand, IRequest<Result>;

public sealed class UpdateTenantLogoValidator : AbstractValidator<UpdateTenantLogoCommand>
{
public UpdateTenantLogoValidator()
{
RuleFor(x => x.ContentType)
.Must(x => x is "image/jpeg" or "image/png" or "image/gif" or "image/webp" or "image/svg+xml")
.WithMessage(_ => "Image must be of type JPEG, PNG, GIF, WebP, or SVG.");

RuleFor(x => x.FileStream.Length)
.LessThanOrEqualTo(2 * 1024 * 1024)
.WithMessage(_ => "Image must be smaller than 2 MB");
}
}

public sealed class UpdateTenantLogoHandler(
ITenantRepository tenantRepository,
IExecutionContext executionContext,
[FromKeyedServices("account-management-storage")]
BlobStorageClient blobStorageClient,
ITelemetryEventsCollector events
)
: IRequestHandler<UpdateTenantLogoCommand, Result>
{
private const string ContainerName = "logos";

public async Task<Result> Handle(UpdateTenantLogoCommand command, CancellationToken cancellationToken)
{
if (executionContext.UserInfo.Role != UserRole.Owner.ToString())
{
return Result.Forbidden("Only owners are allowed to update tenant logo.");
}

var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken);

var fileHash = await GetFileHash(command.FileStream, cancellationToken);
var fileExtension = GetFileExtension(command.ContentType);
var blobName = $"{tenant.Id}/logo/{fileHash}.{fileExtension}";
var logoUrl = $"/{ContainerName}/{blobName}";

if (tenant.Logo.Url != logoUrl)
{
await blobStorageClient.UploadAsync(ContainerName, blobName, command.ContentType, command.FileStream, cancellationToken);

tenant.UpdateLogo(logoUrl);
tenantRepository.Update(tenant);

events.CollectEvent(new TenantLogoUpdated(command.ContentType, command.FileStream.Length));
}

return Result.Success();
}

private static async Task<string> GetFileHash(Stream fileStream, CancellationToken cancellationToken)
{
using var sha1 = SHA1.Create();

Check warning on line 71 in application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure this weak hash algorithm is not used in a sensitive context here. (https://rules.sonarsource.com/csharp/RSPEC-4790)

Check warning on line 71 in application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure this weak hash algorithm is not used in a sensitive context here. (https://rules.sonarsource.com/csharp/RSPEC-4790)

Check warning on line 71 in application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure this weak hash algorithm is not used in a sensitive context here. (https://rules.sonarsource.com/csharp/RSPEC-4790)

Check warning on line 71 in application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs

View workflow job for this annotation

GitHub Actions / Build and Test

Make sure this weak hash algorithm is not used in a sensitive context here. (https://rules.sonarsource.com/csharp/RSPEC-4790)
var hashBytes = await sha1.ComputeHashAsync(fileStream, cancellationToken);
fileStream.Position = 0;
// This just needs to be unique for one tenant, who likely will ever only have one logo, so 16 chars should be enough
return BitConverter.ToString(hashBytes).Replace("-", "")[..16].ToUpper();
}

private static string GetFileExtension(string contentType)
{
return contentType switch
{
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
_ => throw new InvalidOperationException($"Unsupported content type: {contentType}")
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ public sealed class Tenant : AggregateRoot<TenantId>
private Tenant() : base(TenantId.NewId())
{
State = TenantState.Trial;
Logo = new Logo();
}

public string Name { get; private set; } = string.Empty;

public TenantState State { get; private set; }

public Logo Logo { get; private set; }

public static Tenant Create(string email)
{
var tenant = new Tenant();
Expand All @@ -24,4 +27,16 @@ public void Update(string tenantName)
{
Name = tenantName;
}

public void UpdateLogo(string logoUrl)
{
Logo = new Logo(logoUrl, Logo.Version + 1);
}

public void RemoveLogo()
{
Logo = new Logo(Version: Logo.Version);
}
}

public sealed record Logo(string? Url = null, int Version = 0);
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ public sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
public void Configure(EntityTypeBuilder<Tenant> builder)
{
builder.MapStronglyTypedLongId<Tenant, TenantId>(t => t.Id);

builder.OwnsOne(t => t.Logo, b => b.ToJson());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ namespace PlatformPlatform.AccountManagement.Features.Tenants.Queries;
public sealed record GetCurrentTenantQuery : IRequest<Result<TenantResponse>>;

[PublicAPI]
public sealed record TenantResponse(TenantId Id, DateTimeOffset CreatedAt, DateTimeOffset? ModifiedAt, string Name, TenantState State);
public sealed record TenantResponse(
TenantId Id,
DateTimeOffset CreatedAt,
DateTimeOffset? ModifiedAt,
string Name,
TenantState State,
string? LogoUrl
);

public sealed class GetTenantHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetCurrentTenantQuery, Result<TenantResponse>>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Mapster;
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
using PlatformPlatform.AccountManagement.Features.Tenants.Queries;

namespace PlatformPlatform.AccountManagement.Features.Tenants;

public static class TenantMapsterConfig
{
public static void Configure()
{
TypeAdapterConfig<Tenant, TenantResponse>
.NewConfig()
.Map(dest => dest.LogoUrl, src => src.Logo.Url);
}
}
Loading
Loading