many fixes and improvements - rework for modules/ and common/
feat(emailer): add Postmark and Resend providers
This commit is contained in:
33
backend/Infrastructure/BlobStorage/BlobStructure.txt
Normal file
33
backend/Infrastructure/BlobStorage/BlobStructure.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
users/
|
||||
│
|
||||
├── userId1/
|
||||
│ ├── profile/
|
||||
│ │ └── profilePicture.jpg
|
||||
│ │ └── data.json
|
||||
│ │
|
||||
│ ├── posts/
|
||||
│ │ ├── post1/
|
||||
│ │ │ ├── image1.jpg
|
||||
│ │ │ ├── video1.mp4
|
||||
│ │ │ └── audio1.mp3
|
||||
│ │ ├── post2/
|
||||
│ │ │ ├── image2.jpg
|
||||
│ │ │ └── video2.mp4
|
||||
│ │ └── ...
|
||||
│
|
||||
├── userId2/
|
||||
│ ├── profile/
|
||||
│ │ └── profilePicture.jpg
|
||||
│ │ └── data.json
|
||||
│ │
|
||||
│ ├── posts/
|
||||
│ │ ├── post1/
|
||||
│ │ │ ├── image1.jpg
|
||||
│ │ │ ├── video1.mp4
|
||||
│ │ │ └── audio1.mp3
|
||||
│ │ ├── post2/
|
||||
│ │ │ ├── image2.jpg
|
||||
│ │ │ └── video2.mp4
|
||||
│ │ └── ...
|
||||
│
|
||||
└── ...
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class CommonFileNames
|
||||
{
|
||||
public const string ProfilePicture = "profilePicture";
|
||||
public const string BannerPicture = "bannerPicture";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Creators = "creators";
|
||||
}
|
||||
44
backend/Infrastructure/BlobStorage/Contracts/ContentTypes.cs
Normal file
44
backend/Infrastructure/BlobStorage/Contracts/ContentTypes.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class ContentTypes
|
||||
{
|
||||
private const string ImagePng = "image/png";
|
||||
private const string ImageJpeg = "image/jpeg";
|
||||
private const string ImageJpg = "image/jpg";
|
||||
private const string TextHtml = "text/html";
|
||||
|
||||
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
|
||||
|
||||
public static bool IsAllowed(
|
||||
string contentType,
|
||||
Stream fileStream)
|
||||
{
|
||||
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
|
||||
}
|
||||
|
||||
private static bool IsValidFileType(
|
||||
Stream fileStream)
|
||||
{
|
||||
byte[] buffer = new byte[512];
|
||||
_ = fileStream.Read(buffer, 0, buffer.Length);
|
||||
fileStream.Position = 0;
|
||||
|
||||
// PNG file signature: 89 50 4E 47 (in hex)
|
||||
if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// JPEG file signature: FF D8 FF (in hex)
|
||||
if (buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
|
||||
string content = Encoding.UTF8.GetString(buffer);
|
||||
return content.Contains("<!DOCTYPE html>");
|
||||
}
|
||||
}
|
||||
32
backend/Infrastructure/BlobStorage/Contracts/IBlobStorage.cs
Normal file
32
backend/Infrastructure/BlobStorage/Contracts/IBlobStorage.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public interface IBlobStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Upload a file to blob storage.
|
||||
/// </summary>
|
||||
/// <param name="containerName">The name of the container where the file is stored.</param>
|
||||
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
|
||||
/// <param name="stream"></param>
|
||||
/// <param name="contentType">The content type.</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns></returns>
|
||||
Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Download a file to blob storage.
|
||||
/// </summary>
|
||||
/// <param name="blobName">The blob name (path within the container).</param>
|
||||
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
|
||||
/// <param name="ct">The cancellation token for the request</param>
|
||||
/// <returns></returns>
|
||||
Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
public static class SubDirectoryNames
|
||||
{
|
||||
public const string Profile = "profile";
|
||||
public const string Contents = "contents";
|
||||
public const string Albums = "albums";
|
||||
}
|
||||
154
backend/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs
Normal file
154
backend/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure.BlobStorage.Services;
|
||||
|
||||
public class AzureBlobStorage : IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
|
||||
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<AzureBlobStorage> _logger;
|
||||
|
||||
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var connectionString = configuration.GetConnectionString("AzureBlob");
|
||||
_blobServiceClient = new BlobServiceClient(connectionString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a file to microsoft azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="containerName">The name of the container where the file is stored.</param>
|
||||
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
|
||||
/// <param name="stream"></param>
|
||||
/// <param name="contentType">The content type.</param>
|
||||
/// <param name="ct">The cancellation token</param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> UploadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
Stream stream,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Read the file stream into a memory stream to determine the length
|
||||
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
|
||||
stream.Position = 0;
|
||||
|
||||
// Check if the file size exceeds the maximum upload size
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
throw new InvalidOperationException(
|
||||
$"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Blob storage: Unsupported file type {contentType}.");
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Create the container if it does not exist
|
||||
await containerClient.CreateIfNotExistsAsync(
|
||||
PublicAccessType.Blob,
|
||||
cancellationToken: ct);
|
||||
|
||||
// Get a reference to a blob
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Define the BlobHttpHeaders to include the content type
|
||||
var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType };
|
||||
|
||||
// Upload the file
|
||||
var response = await blobClient.UploadAsync(
|
||||
stream,
|
||||
new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
|
||||
ct);
|
||||
|
||||
var fileUri = blobClient.Uri.ToString();
|
||||
|
||||
_logger.LogInformation(
|
||||
"""
|
||||
Blob storage: Status [ {ResponseStatus} ]
|
||||
Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
|
||||
with contentType [ {ContentType} ]
|
||||
with a length of [ {StreamLength} bytes ]
|
||||
with the uri [ {FileUri} ]
|
||||
""",
|
||||
response.GetRawResponse().Status.ToString(),
|
||||
blobName,
|
||||
containerName,
|
||||
contentType,
|
||||
stream.Length,
|
||||
fileUri
|
||||
);
|
||||
|
||||
// Return the URI of the uploaded blob
|
||||
return fileUri;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download a file to microsoft's azure blob storage.
|
||||
/// </summary>
|
||||
/// <param name="blobName">The blob name (path within the container).</param>
|
||||
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
|
||||
/// <param name="ct">The cancellation token for the request</param>
|
||||
/// <returns></returns>
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
string containerName,
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get a reference to a container
|
||||
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
|
||||
|
||||
// Get a reference to a blob
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
|
||||
// Download the blob to a stream
|
||||
BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await download.Content.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0; // Ensure the stream is at the beginning
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
_logger.LogError($"Azure Storage request failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"An error occurred: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/Infrastructure/Configuration/WebsiteOptions.cs
Normal file
8
backend/Infrastructure/Configuration/WebsiteOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Hutopy.Infrastructure.Configuration;
|
||||
|
||||
public class WebsiteOptions
|
||||
{
|
||||
public const string SectionName = "Website";
|
||||
|
||||
public string FrontendBaseUrl { get; set; } = "https://localhost:5173";
|
||||
}
|
||||
40
backend/Infrastructure/DependencyInjection.cs
Normal file
40
backend/Infrastructure/DependencyInjection.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Infrastructure.BlobStorage.Services;
|
||||
using Hutopy.Infrastructure.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Hutopy.Infrastructure.Emailer.Services;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddInfrastructureModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<WebsiteOptions>(
|
||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
||||
|
||||
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
|
||||
|
||||
builder.Services.AddTransient<ITipProcessor, StripeTipProcessor>();
|
||||
builder.Services.AddTransient<IMembershipPaymentProcessor, MembershipPaymentProcessor>();
|
||||
builder.Services.AddTransient<IMembershipCancellationProcessor, MembershipCancellationProcessor>();
|
||||
builder.Services.AddTransient<IMembershipTierProcessor, MembershipTierProcessor>();
|
||||
builder.Services.Configure<StripeOptions>(
|
||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||
|
||||
builder.Services.Configure<EmailerOptions>(
|
||||
builder.Configuration.GetSection(EmailerOptions.ConfigurationSection));
|
||||
builder.Services.AddTransient<IEmailSender, ResendEmailSender>();
|
||||
//builder.Services.AddTransient<IEmailSender, EmailSender>();
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Hutopy.Infrastructure.Emailer.Configuration;
|
||||
|
||||
public class EmailerOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Emailer";
|
||||
|
||||
public string ApiKey { get; set; } = default!;
|
||||
public string FromEmail { get; set; } = default!;
|
||||
}
|
||||
6
backend/Infrastructure/Emailer/Contracts/IEmailSender.cs
Normal file
6
backend/Infrastructure/Emailer/Contracts/IEmailSender.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Hutopy.Infrastructure.Emailer.Contracts;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string email, string subject, string message);
|
||||
}
|
||||
22
backend/Infrastructure/Emailer/Services/LoggerEmailSender.cs
Normal file
22
backend/Infrastructure/Emailer/Services/LoggerEmailSender.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
|
||||
namespace Hutopy.Infrastructure.Emailer.Services;
|
||||
|
||||
public class LoggerEmailSender(ILogger<IEmailSender> logger)
|
||||
: IEmailSender
|
||||
{
|
||||
public async Task SendEmailAsync(string email, string subject, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject);
|
||||
await Task.Delay(1000);
|
||||
logger.LogInformation("Email sent successfully to {Email}", email);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send email to {Email}", email);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Hutopy.Infrastructure.Emailer.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PostmarkDotNet;
|
||||
|
||||
namespace Hutopy.Infrastructure.Emailer.Services;
|
||||
|
||||
public class PostmarkEmailSender : IEmailSender
|
||||
{
|
||||
private readonly PostmarkClient _client;
|
||||
private readonly EmailerOptions _options;
|
||||
|
||||
public PostmarkEmailSender(IOptions<EmailerOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
_client = new PostmarkClient(_options.ApiKey);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string email, string subject, string message)
|
||||
{
|
||||
PostmarkResponse? sendResult = await _client.SendMessageAsync(new PostmarkMessage
|
||||
{
|
||||
From = _options.FromEmail,
|
||||
To = email,
|
||||
Subject = subject,
|
||||
HtmlBody = message,
|
||||
TrackOpens = true,
|
||||
MessageStream = "outbound" // Optional: use "broadcast" for bulk
|
||||
});
|
||||
|
||||
if (sendResult.Status != PostmarkStatus.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Postmark failed to send email: {sendResult.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
46
backend/Infrastructure/Emailer/Services/ResendEmailSender.cs
Normal file
46
backend/Infrastructure/Emailer/Services/ResendEmailSender.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Hutopy.Infrastructure.Emailer.Configuration;
|
||||
using Hutopy.Infrastructure.Emailer.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Hutopy.Infrastructure.Emailer.Services;
|
||||
|
||||
public class ResendEmailSender : IEmailSender
|
||||
{
|
||||
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly EmailerOptions _options;
|
||||
|
||||
public ResendEmailSender(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<EmailerOptions> options)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_options = options.Value;
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
|
||||
{
|
||||
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage };
|
||||
|
||||
string json = JsonSerializer.Serialize(payload);
|
||||
StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Resend email failed: {response.StatusCode} - {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
|
||||
public class StripeOptions
|
||||
{
|
||||
public const string ConfigurationSection = "Stripe";
|
||||
|
||||
[Required] public required string SecretKey { get; init; }
|
||||
|
||||
[Required] public required string WebhookSecret { get; init; }
|
||||
|
||||
[Required] [Range(0, 1)] public required decimal HutopyRate { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public sealed class MembershipCancellationProcessor
|
||||
: IMembershipCancellationProcessor
|
||||
{
|
||||
public async Task<DateTimeOffset?> CancelAsync(
|
||||
string subscriptionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
SubscriptionService subscriptionService = new();
|
||||
|
||||
// Stripe - Cancel Subscription immediately
|
||||
// var subscription = await subscriptionService.CancelAsync(
|
||||
// subscriptionId,
|
||||
// cancellationToken: ct);
|
||||
|
||||
// Stripe - Cancel Subscription AtPeriodEnd
|
||||
Subscription? subscription = await subscriptionService.UpdateAsync(
|
||||
subscriptionId,
|
||||
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true },
|
||||
cancellationToken: ct);
|
||||
|
||||
return subscription.CancelAt ?? subscription.CanceledAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public class MembershipPaymentProcessor(
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: IMembershipPaymentProcessor
|
||||
{
|
||||
public async Task<MembershipCheckoutSession> CreateCheckoutSessionAsync(
|
||||
Guid userId,
|
||||
CreatorReference creatorReference,
|
||||
Guid tierId,
|
||||
string priceId,
|
||||
string successUrl,
|
||||
string cancelUrl)
|
||||
{
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
|
||||
// Create Stripe customer for the user if not already created
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.CreateAsync(
|
||||
new CustomerCreateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
|
||||
});
|
||||
|
||||
// Create Checkout Session for the subscription
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.CreateAsync(
|
||||
new SessionCreateOptions
|
||||
{
|
||||
Customer = customer.Id,
|
||||
PaymentMethodTypes = ["card"],
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
|
||||
],
|
||||
Mode = "subscription",
|
||||
SubscriptionData = new SessionSubscriptionDataOptions
|
||||
{
|
||||
ApplicationFeePercent = stripeOptions.Value.HutopyRate,
|
||||
TransferData = new SessionSubscriptionDataTransferDataOptions { Destination = creatorReference.StripeAccountId }
|
||||
},
|
||||
SuccessUrl = successUrl, // Redirect after successful payment
|
||||
CancelUrl = cancelUrl, // Redirect after canceled payment
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "userId", userId.ToString() },
|
||||
{ "creatorId", creatorReference.Id.ToString() },
|
||||
{ "creatorName", creatorReference.Name },
|
||||
{ "tierId", tierId.ToString() }
|
||||
}
|
||||
});
|
||||
|
||||
return new MembershipCheckoutSession(
|
||||
session.Id,
|
||||
session.Url);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public sealed class MembershipTierProcessor(
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: IMembershipTierProcessor
|
||||
{
|
||||
public async Task<string> CreateAsync(
|
||||
Guid creatorId,
|
||||
Guid tierId,
|
||||
string productName,
|
||||
string currencyCode,
|
||||
decimal amount)
|
||||
{
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
|
||||
// Create the product
|
||||
var productService = new ProductService();
|
||||
var product = await productService.CreateAsync(
|
||||
new ProductCreateOptions
|
||||
{
|
||||
Name = productName,
|
||||
Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } }
|
||||
});
|
||||
|
||||
// Create the price for the product
|
||||
var priceService = new PriceService();
|
||||
await priceService.CreateAsync(
|
||||
new PriceCreateOptions
|
||||
{
|
||||
Product = product.Id,
|
||||
UnitAmountDecimal = amount * 100, // Convert amount to cents
|
||||
Currency = currencyCode,
|
||||
Recurring = new PriceRecurringOptions { Interval = "month" }
|
||||
});
|
||||
|
||||
return product.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Hutopy.Infrastructure.Payments.Stripe.Services;
|
||||
|
||||
public class StripeTipProcessor(
|
||||
IOptions<StripeOptions> stripeOptions)
|
||||
: ITipProcessor
|
||||
{
|
||||
public async Task<TipCheckoutSession> CreateCheckoutSessionAsync(
|
||||
Guid tipId,
|
||||
CreatorReference creator,
|
||||
decimal amount,
|
||||
string currency,
|
||||
string message,
|
||||
string successUrl,
|
||||
string cancelUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
|
||||
|
||||
// Create Stripe customer for the user if not already created
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.CreateAsync(
|
||||
new CustomerCreateOptions(),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Create paymentIntent for the user
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.CreateAsync(
|
||||
new SessionCreateOptions
|
||||
{
|
||||
ClientReferenceId = tipId.ToString(),
|
||||
Customer = customer.Id,
|
||||
PaymentMethodTypes = ["card"],
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = currency,
|
||||
UnitAmountDecimal = amount, // Amount in cents
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = $"Tip for {creator.Name}", // or any descriptive name for the tip
|
||||
Metadata = new Dictionary<string, string> { { "creatorId", creator.Id.ToString() } }
|
||||
}
|
||||
},
|
||||
Quantity = 1
|
||||
}
|
||||
],
|
||||
Mode = "payment",
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions
|
||||
{
|
||||
ApplicationFeeAmount =
|
||||
Convert.ToInt64(amount * 100 * stripeOptions.Value.HutopyRate), // Platform fee
|
||||
TransferData = new SessionPaymentIntentDataTransferDataOptions
|
||||
{
|
||||
Destination = creator.StripeAccountId // Creator's Stripe account ID
|
||||
}
|
||||
},
|
||||
SuccessUrl = successUrl, // Redirect after successful payment
|
||||
CancelUrl = cancelUrl, // Redirect after canceled payment
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message },
|
||||
}
|
||||
},
|
||||
cancellationToken: ct);
|
||||
|
||||
return new TipCheckoutSession(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
57
backend/Infrastructure/Security/ClaimsPrincipalExtensions.cs
Normal file
57
backend/Infrastructure/Security/ClaimsPrincipalExtensions.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static Guid GetUserId(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (Guid)claims.GetRequiredClaim<Guid>(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
|
||||
public static string GetName(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Name);
|
||||
}
|
||||
|
||||
public static string? GetAlias(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string?)claims.GetClaim<string?>(KnownClaims.Alias);
|
||||
}
|
||||
|
||||
public static string? GetPortraitUrl(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string?)claims.GetClaim<string?>(KnownClaims.PortraitUrl);
|
||||
}
|
||||
|
||||
public static string GetFirstName(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string)claims.GetRequiredClaim<string>(ClaimTypes.GivenName);
|
||||
}
|
||||
|
||||
public static string GetLastName(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Surname);
|
||||
}
|
||||
|
||||
public static string GetEmail(this ClaimsPrincipal claims)
|
||||
{
|
||||
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Email);
|
||||
}
|
||||
|
||||
private static object? GetClaim<TValue>(this ClaimsPrincipal claims, string key)
|
||||
{
|
||||
var claim = claims.FindFirst(key);
|
||||
|
||||
return claim is null ? null : claims.GetRequiredClaim<TValue>(key);
|
||||
}
|
||||
|
||||
private static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key)
|
||||
{
|
||||
var claim = claims.FindFirst(key);
|
||||
|
||||
if (claim is null) throw new MissingClaimException(key);
|
||||
|
||||
return typeof(TValue) == typeof(Guid) ? Guid.Parse(claim.Value) : Convert.ChangeType(claim.Value, typeof(TValue));
|
||||
}
|
||||
}
|
||||
52
backend/Infrastructure/Security/GenerateJwtToken.cs
Normal file
52
backend/Infrastructure/Security/GenerateJwtToken.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
public static class JwtTokenHelper
|
||||
{
|
||||
public static string GenerateJwtToken(
|
||||
TimeSpan expiresIn,
|
||||
string issuer,
|
||||
string audience,
|
||||
string key,
|
||||
string userId,
|
||||
string email,
|
||||
string? alias,
|
||||
string firstname,
|
||||
string lastname,
|
||||
string? portraitUrl)
|
||||
{
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>([
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Email, email),
|
||||
new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.GivenName, firstname),
|
||||
new Claim(ClaimTypes.Surname, lastname)
|
||||
]);
|
||||
|
||||
if (alias is not null)
|
||||
{
|
||||
claims.Add(new Claim(KnownClaims.Alias, alias));
|
||||
}
|
||||
|
||||
if (portraitUrl is not null)
|
||||
{
|
||||
claims.Add(new Claim(KnownClaims.PortraitUrl, portraitUrl));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: DateTime.Now.Add(expiresIn),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
7
backend/Infrastructure/Security/KnownClaims.cs
Normal file
7
backend/Infrastructure/Security/KnownClaims.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
public static class KnownClaims
|
||||
{
|
||||
public const string Alias = "alias";
|
||||
public const string PortraitUrl = "portraitUrl";
|
||||
}
|
||||
5
backend/Infrastructure/Security/MissingClaimException.cs
Normal file
5
backend/Infrastructure/Security/MissingClaimException.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
public class MissingClaimException(
|
||||
string claimName)
|
||||
: Exception($"Claim '{claimName}' is missing.");
|
||||
76
backend/Infrastructure/Security/PasswordGenerator.cs
Normal file
76
backend/Infrastructure/Security/PasswordGenerator.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
// If we need to add special characters we can alternate between 2 pools.
|
||||
public static class PasswordGenerator
|
||||
{
|
||||
private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz";
|
||||
private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
private const string Numbers = "0123456789";
|
||||
private const string SpecialCharacters = "!@#$%^&*()_+-=[];',./`~{}|:\"<>?";
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
public static string Next(
|
||||
int length = 15,
|
||||
bool requireNumber = true,
|
||||
bool requireLowercase = true,
|
||||
bool requireCapital = true,
|
||||
bool requireSpecialCharacter = true)
|
||||
{
|
||||
// Create pools based on the requirements
|
||||
var characterPool = new StringBuilder();
|
||||
|
||||
if (requireNumber)
|
||||
characterPool.Append(LowerLetters);
|
||||
|
||||
if (requireCapital)
|
||||
characterPool.Append(UpperLetters);
|
||||
|
||||
if (requireNumber)
|
||||
characterPool.Append(Numbers);
|
||||
|
||||
if (requireSpecialCharacter)
|
||||
characterPool.Append(SpecialCharacters);
|
||||
|
||||
// Ensure that the length is within the specified bounds
|
||||
var password = new char[length];
|
||||
|
||||
// Ensure at least one character from each required category is included
|
||||
int index = 0;
|
||||
|
||||
if (requireLowercase)
|
||||
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)];
|
||||
|
||||
if (requireCapital)
|
||||
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)];
|
||||
|
||||
if (requireNumber)
|
||||
password[index++] = Numbers[Random.Next(Numbers.Length)];
|
||||
|
||||
if (requireSpecialCharacter)
|
||||
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)];
|
||||
|
||||
// Fill the rest with the password
|
||||
for (int i = index; i < length; i++)
|
||||
{
|
||||
password[i] = characterPool[RandomNumberGenerator.GetInt32(characterPool.Length)];
|
||||
}
|
||||
|
||||
// Shuffle the password to randomize the placement of the required characters
|
||||
Shuffle(password);
|
||||
return new string(password);
|
||||
}
|
||||
|
||||
private static void Shuffle(
|
||||
char[] array)
|
||||
{
|
||||
for (int i = array.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = Random.Next(i + 1);
|
||||
(array[i], array[j]) = (array[j], array[i]); // Swap elements
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/Infrastructure/Security/RefreshTokenGenerator.cs
Normal file
13
backend/Infrastructure/Security/RefreshTokenGenerator.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Hutopy.Infrastructure.Security;
|
||||
|
||||
public static class RefreshTokenGenerator
|
||||
{
|
||||
public static string Next()
|
||||
{
|
||||
var randomNumber = new byte[32];
|
||||
RandomNumberGenerator.Fill(randomNumber);
|
||||
return Convert.ToBase64String(randomNumber);
|
||||
}
|
||||
}
|
||||
64
backend/Infrastructure/YouTube/YouTubeUrlHelper.cs
Normal file
64
backend/Infrastructure/YouTube/YouTubeUrlHelper.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Hutopy.Infrastructure.YouTube;
|
||||
|
||||
public static class YouTubeUrlHelper
|
||||
{
|
||||
private static readonly Regex VideoIdRegex = new(
|
||||
@"(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^""&?/\s]{11})",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex ShortUrlRegex = new(
|
||||
@"^[a-zA-Z0-9_-]{11}$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the video ID from a YouTube URL or returns the input if it's already a video ID.
|
||||
/// </summary>
|
||||
/// <param name="input">The YouTube URL or video ID</param>
|
||||
/// <returns>The extracted video ID or null if invalid</returns>
|
||||
public static string? ExtractVideoId(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return null;
|
||||
|
||||
// If it's already a valid video ID, return it
|
||||
if (IsValidVideoId(input))
|
||||
return input;
|
||||
|
||||
// Try to extract video ID from URL
|
||||
var match = VideoIdRegex.Match(input);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if the input is a valid YouTube video ID.
|
||||
/// </summary>
|
||||
/// <param name="input">The video ID to validate</param>
|
||||
/// <returns>True if the input is a valid video ID</returns>
|
||||
public static bool IsValidVideoId(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
return ShortUrlRegex.IsMatch(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if the input is a valid YouTube URL or video ID.
|
||||
/// </summary>
|
||||
/// <param name="input">The URL or video ID to validate</param>
|
||||
/// <returns>True if the input is a valid YouTube URL or video ID</returns>
|
||||
public static bool IsValidYouTubeUrlOrId(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
// Check if it's a valid video ID
|
||||
if (IsValidVideoId(input))
|
||||
return true;
|
||||
|
||||
// Check if it's a valid YouTube URL
|
||||
return VideoIdRegex.IsMatch(input);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user