Skip to content

Commit a82ff4e

Browse files
Merge pull request #225 from leancodepl/feature/file-upload
File upload + Service Provider logos
2 parents 8648bbc + 249cfb3 commit a82ff4e

File tree

19 files changed

+702
-9
lines changed

19 files changed

+702
-9
lines changed

.template.config/template.json

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"**/Migrations/*.cs",
2727
"**/Configuration/MetabaseConfiguration.cs",
2828
"**/DataAccess/*.ExamplesDomain.cs",
29+
"**/DataAccess/Blobs/ServiceProviderLogoStorage.cs",
2930
"**/DataAccess/Queries/*.cs",
3031
"**/DataAccess/Repositories/*.cs",
3132
"**/Handlers/Booking/**/*.cs",
@@ -43,6 +44,7 @@
4344
"**/*.Domain.Tests/**/*.cs",
4445
"**/*.IntegrationTests/Booking/**",
4546
"**/*.IntegrationTests/Example/**",
47+
"**/*.IntegrationTests/Helpers/ServiceProviderLogoStorageMock.cs",
4648
"**/*.Tests/Handlers/**",
4749
"**/Jenkinsfile",
4850
"**/docs"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using LeanCode.Contracts;
2+
using LeanCode.Contracts.Security;
3+
4+
namespace ExampleApp.Examples.Contracts.Booking.Management;
5+
6+
[AuthorizeWhenHasAnyOf(Auth.Roles.Admin)]
7+
public class ServiceProviderLogoUploadLink : IQuery<ServiceProviderLogoUploadLinkDTO> { }
8+
9+
public class ServiceProviderLogoUploadLinkDTO
10+
{
11+
public Uri Link { get; set; }
12+
public Dictionary<string, string> RequiredHeaders { get; set; }
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Text.Json.Serialization;
2+
using LeanCode.DomainModels.Model;
3+
using LeanCode.TimeProvider;
4+
5+
namespace ExampleApp.Examples.Domain.Booking.Events;
6+
7+
public class ServiceProviderCreated : IDomainEvent
8+
{
9+
public Guid Id { get; }
10+
public DateTime DateOccurred { get; }
11+
12+
public ServiceProviderId ServiceProviderId { get; }
13+
14+
public ServiceProviderCreated(ServiceProvider serviceProvider)
15+
{
16+
Id = Guid.NewGuid();
17+
DateOccurred = Time.UtcNow;
18+
19+
ServiceProviderId = serviceProvider.Id;
20+
}
21+
22+
[JsonConstructor]
23+
public ServiceProviderCreated(Guid id, DateTime dateOccurred, ServiceProviderId serviceProviderId)
24+
{
25+
Id = id;
26+
DateOccurred = dateOccurred;
27+
ServiceProviderId = serviceProviderId;
28+
}
29+
}

backend/src/Examples/ExampleApp.Examples.Domain/Booking/ServiceProvider.cs

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using ExampleApp.Examples.Domain.Booking.Events;
12
using LeanCode.DomainModels.Ids;
23
using LeanCode.DomainModels.Model;
34

@@ -44,7 +45,7 @@ public static ServiceProvider Create(
4445
double ratings
4546
)
4647
{
47-
return new ServiceProvider
48+
var serviceProvider = new ServiceProvider
4849
{
4950
Id = ServiceProviderId.New(),
5051
Name = name,
@@ -56,6 +57,10 @@ double ratings
5657
Location = location,
5758
Ratings = ratings,
5859
};
60+
61+
DomainEvents.Raise(new ServiceProviderCreated(serviceProvider));
62+
63+
return serviceProvider;
5964
}
6065
}
6166

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Azure.Storage.Blobs;
3+
using Azure.Storage.Blobs.Models;
4+
using Azure.Storage.Sas;
5+
6+
namespace ExampleApp.Examples.DataAccess.Blobs;
7+
8+
public abstract class BaseBlobStorage
9+
{
10+
private const string DeleteTagName = "ToDelete";
11+
private const string DeleteValue = "1";
12+
13+
private readonly BlobStorageDelegationKeyProvider keyProvider;
14+
15+
protected BlobServiceClient Client { get; }
16+
protected abstract string ContainerName { get; }
17+
protected abstract PublicAccessType DefaultAccessType { get; }
18+
19+
protected BaseBlobStorage(BlobServiceClient client, BlobStorageDelegationKeyProvider keyProvider)
20+
{
21+
Client = client;
22+
this.keyProvider = keyProvider;
23+
}
24+
25+
public virtual bool IsValid(Uri uri)
26+
{
27+
var blobBuilder = new BlobUriBuilder(uri, false);
28+
return IsValid(blobBuilder);
29+
}
30+
31+
[return: NotNullIfNotNull(nameof(uri))]
32+
public virtual Uri? PrepareForStorage(Uri? uri)
33+
{
34+
if (uri is null)
35+
{
36+
return null;
37+
}
38+
else
39+
{
40+
ValidateUri(uri);
41+
return new UriBuilder(uri)
42+
{
43+
Query = null,
44+
Port = -1,
45+
Fragment = null,
46+
}.Uri;
47+
}
48+
}
49+
50+
protected abstract bool IsValidBlobName(string blobName);
51+
52+
protected async Task<Uri> GetTemporaryUploadLinkAsync(string filename, CancellationToken cancellationToken)
53+
{
54+
var blob = await GetBlobClientAsync(filename, cancellationToken);
55+
56+
await blob.UploadAsync(
57+
Stream.Null,
58+
new BlobUploadOptions { Tags = new Dictionary<string, string> { [DeleteTagName] = DeleteValue } },
59+
cancellationToken
60+
);
61+
62+
return await GenerateBlobSasAsync(
63+
blob,
64+
BlobSasPermissions.Write | BlobSasPermissions.Read,
65+
null,
66+
cancellationToken
67+
);
68+
}
69+
70+
protected async Task CommitTemporaryUploadAsync(Uri uri, CancellationToken cancellationToken)
71+
{
72+
ValidateUri(uri);
73+
74+
var blobUri = new BlobUriBuilder(uri);
75+
var container = Client.GetBlobContainerClient(ContainerName);
76+
var blob = container.GetBlobClient(blobUri.BlobName);
77+
78+
await blob.SetTagsAsync(new Dictionary<string, string>(), cancellationToken: cancellationToken);
79+
}
80+
81+
protected async Task<Uri> GetPermanentUploadLinkAsync(string filename, CancellationToken cancellationToken)
82+
{
83+
var blob = await GetBlobClientAsync(filename, cancellationToken);
84+
85+
return await GenerateBlobSasAsync(
86+
blob,
87+
BlobSasPermissions.Create | BlobSasPermissions.Write | BlobSasPermissions.Read,
88+
null,
89+
cancellationToken
90+
);
91+
}
92+
93+
protected async Task DeleteAsync(Uri uri, CancellationToken cancellationToken)
94+
{
95+
ValidateUri(uri);
96+
97+
var blobUri = new BlobUriBuilder(uri);
98+
var container = Client.GetBlobContainerClient(ContainerName);
99+
var blob = container.GetBlobClient(blobUri.BlobName);
100+
await blob.DeleteIfExistsAsync(DeleteSnapshotsOption.IncludeSnapshots, cancellationToken: cancellationToken);
101+
}
102+
103+
protected async Task<Uri> GetDownloadLinkAsync(
104+
string filename,
105+
string? contentDisposition = null,
106+
CancellationToken cancellationToken = default
107+
)
108+
{
109+
var blob = GetBlobClient(filename);
110+
return await GenerateBlobSasAsync(blob, BlobSasPermissions.Read, contentDisposition, cancellationToken);
111+
}
112+
113+
protected async Task<Uri> GetDownloadLinkAsync(
114+
Uri uri,
115+
string? contentDisposition = null,
116+
CancellationToken cancellationToken = default
117+
)
118+
{
119+
var blob = GetBlobClient(uri);
120+
return await GenerateBlobSasAsync(blob, BlobSasPermissions.Read, contentDisposition, cancellationToken);
121+
}
122+
123+
protected BlobClient GetBlobClient(string filename)
124+
{
125+
var container = Client.GetBlobContainerClient(ContainerName);
126+
return container.GetBlobClient(filename);
127+
}
128+
129+
protected BlobClient GetBlobClient(Uri uri)
130+
{
131+
var parsed = Parse(uri);
132+
var container = Client.GetBlobContainerClient(ContainerName);
133+
return container.GetBlobClient(parsed.BlobName);
134+
}
135+
136+
protected async Task<BlobClient> GetBlobClientAsync(string filename, CancellationToken cancellationToken = default)
137+
{
138+
var container = Client.GetBlobContainerClient(ContainerName);
139+
await container.CreateIfNotExistsAsync(DefaultAccessType, cancellationToken: cancellationToken);
140+
return container.GetBlobClient(filename);
141+
}
142+
143+
protected async Task<Uri> GenerateBlobSasAsync(
144+
BlobClient blob,
145+
BlobSasPermissions permissions,
146+
string? contentDisposition = null,
147+
CancellationToken cancellationToken = default
148+
)
149+
{
150+
var sasBuilder = new BlobSasBuilder(permissions, DateTimeOffset.UtcNow.AddDays(2))
151+
{
152+
BlobName = blob.Name,
153+
BlobContainerName = blob.BlobContainerName,
154+
Resource = "b",
155+
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-3),
156+
ContentDisposition = contentDisposition,
157+
};
158+
159+
if (Client.CanGenerateAccountSasUri)
160+
{
161+
return blob.GenerateSasUri(sasBuilder);
162+
}
163+
else
164+
{
165+
var userDelegationKey = await keyProvider.GetKeyAsync(cancellationToken);
166+
return new BlobUriBuilder(blob.Uri)
167+
{
168+
Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, Client.AccountName),
169+
}.ToUri();
170+
}
171+
}
172+
173+
protected void ValidateUri(Uri uri)
174+
{
175+
if (!IsValid(uri))
176+
{
177+
throw new ArgumentException("The URI is not a valid logo URI.", nameof(uri));
178+
}
179+
}
180+
181+
protected void ValidateUri(BlobUriBuilder uri)
182+
{
183+
if (!IsValid(uri))
184+
{
185+
throw new ArgumentException("The URI is not a valid logo URI.", nameof(uri));
186+
}
187+
}
188+
189+
protected BlobUriBuilder Parse(Uri uri)
190+
{
191+
var builder = new BlobUriBuilder(uri);
192+
ValidateUri(builder);
193+
return builder;
194+
}
195+
196+
private bool IsValid(BlobUriBuilder blobBuilder)
197+
{
198+
return blobBuilder.AccountName == Client.AccountName
199+
&& blobBuilder.BlobContainerName == ContainerName
200+
&& IsValidBlobName(blobBuilder.BlobName);
201+
}
202+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Azure.Storage.Blobs;
2+
using Azure.Storage.Blobs.Models;
3+
using LeanCode.TimeProvider;
4+
using Microsoft.Extensions.Caching.Memory;
5+
6+
namespace ExampleApp.Examples.DataAccess.Blobs;
7+
8+
public class BlobStorageDelegationKeyProvider
9+
{
10+
private const string CacheKey = "user_delegation_key-token";
11+
private static readonly TimeSpan KeyLifetime = TimeSpan.FromDays(7);
12+
private static readonly TimeSpan MaxSasLifetime = TimeSpan.FromDays(2);
13+
private static readonly TimeSpan Skew = TimeSpan.FromMinutes(5);
14+
15+
private readonly Serilog.ILogger logger = Serilog.Log.ForContext<BlobStorageDelegationKeyProvider>();
16+
17+
private readonly IMemoryCache memoryCache;
18+
private readonly BlobServiceClient client;
19+
20+
public BlobStorageDelegationKeyProvider(IMemoryCache memoryCache, BlobServiceClient client)
21+
{
22+
this.memoryCache = memoryCache;
23+
this.client = client;
24+
}
25+
26+
public Task<UserDelegationKey> GetKeyAsync(CancellationToken cancellationToken)
27+
{
28+
return memoryCache.GetOrCreateAsync(CacheKey, e => IssueKeyAsync(e, cancellationToken))!;
29+
}
30+
31+
private async Task<UserDelegationKey> IssueKeyAsync(ICacheEntry entry, CancellationToken cancellationToken)
32+
{
33+
var now = Time.NowWithOffset;
34+
var key = await client.GetUserDelegationKeyAsync(now - Skew, now + KeyLifetime, cancellationToken);
35+
36+
if (key?.Value is null)
37+
{
38+
logger.Warning("Cannot issue blob storage user delegation key");
39+
throw new InvalidOperationException("Cannot issue blob storage user delegation key.");
40+
}
41+
else
42+
{
43+
entry.SetAbsoluteExpiration(now + KeyLifetime - MaxSasLifetime - Skew);
44+
return key;
45+
}
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Azure.Storage.Blobs;
2+
using Azure.Storage.Blobs.Models;
3+
4+
namespace ExampleApp.Examples.DataAccess.Blobs;
5+
6+
public class ServiceProviderLogoStorage : BaseBlobStorage
7+
{
8+
protected override string ContainerName => "service-providers";
9+
protected override PublicAccessType DefaultAccessType => PublicAccessType.Blob;
10+
11+
public ServiceProviderLogoStorage(BlobServiceClient client, BlobStorageDelegationKeyProvider keyProvider)
12+
: base(client, keyProvider) { }
13+
14+
public Dictionary<string, string> GetRequiredUploadHeaders()
15+
{
16+
return new() { ["x-ms-blob-type"] = "blockblob" };
17+
}
18+
19+
public virtual Task<Uri> StartLogoUploadAsync(CancellationToken cancellationToken)
20+
{
21+
return GetTemporaryUploadLinkAsync(Guid.NewGuid().ToString(), cancellationToken);
22+
}
23+
24+
public virtual Task CommitLogoAsync(Uri logoUri, CancellationToken cancellationToken)
25+
{
26+
return CommitTemporaryUploadAsync(logoUri, cancellationToken);
27+
}
28+
29+
public virtual Task DeleteLogoAsync(Uri logoUri, CancellationToken cancellationToken)
30+
{
31+
return DeleteAsync(logoUri, cancellationToken);
32+
}
33+
34+
protected override bool IsValidBlobName(string blobName) => Guid.TryParse(blobName, out _);
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using ExampleApp.Examples.DataAccess.Blobs;
2+
using ExampleApp.Examples.Domain.Booking;
3+
using ExampleApp.Examples.Domain.Booking.Events;
4+
using LeanCode.DomainModels.DataAccess;
5+
using MassTransit;
6+
7+
namespace ExampleApp.Examples.Handlers.Booking.Management;
8+
9+
public class CommitServiceProviderLogosCH(
10+
IRepository<ServiceProvider, ServiceProviderId> serviceProviders,
11+
ServiceProviderLogoStorage logoStorage
12+
) : IConsumer<ServiceProviderCreated>
13+
{
14+
public async Task Consume(ConsumeContext<ServiceProviderCreated> context)
15+
{
16+
var msg = context.Message;
17+
var serviceProvider = await serviceProviders.FindAndEnsureExistsAsync(
18+
msg.ServiceProviderId,
19+
context.CancellationToken
20+
);
21+
22+
await logoStorage.CommitLogoAsync(serviceProvider.CoverPhoto, context.CancellationToken);
23+
await logoStorage.CommitLogoAsync(serviceProvider.Thumbnail, context.CancellationToken);
24+
}
25+
}

0 commit comments

Comments
 (0)