Compare commits

...
59 Commits
Author SHA1 Message Date
khwezi c938bfec09 Merge pull request 'Refactored shasher payfast confirmation response handling' (#55) from payments into master
Reviewed-on: #55
2026-06-01 16:38:44 +02:00
Khwezi Mngoma 5eb6dbc8b2 Refactored shasher payfast confirmation response handling
continuous-integration/drone/pr Build is passing
2026-06-01 16:36:33 +02:00
khwezi adc14038db Merge pull request 'Fixed mdf file name hasher' (#54) from payments into master
Reviewed-on: #54
2026-06-01 09:27:54 +02:00
Khwezi Mngoma afc984f3bc Fixed mdf file name hasher
continuous-integration/drone/pr Build is passing
2026-06-01 09:25:46 +02:00
khwezi 61cb6c2228 Merge pull request 'payments' (#53) from payments into master
Reviewed-on: #53
2026-06-01 09:21:01 +02:00
Khwezi Mngoma f1ef614cbb Removed passphrase from PaymentGateway
continuous-integration/drone/pr Build is failing
2026-06-01 09:19:08 +02:00
Khwezi Mngoma 8fe129e19c Implemented HashService and tests 2026-06-01 09:15:14 +02:00
khwezi e7a798b5e9 Merge pull request 'Added search state model' (#52) from midrandshop into master
Reviewed-on: #52
2026-05-30 19:54:57 +02:00
khwezi 41b6b71b31 Merge pull request 'Redacted Price resolution from GetProductAsync' (#51) from midrandshop into master
Reviewed-on: #51
2026-05-30 18:55:46 +02:00
khwezi ee6beef603 Merge pull request 'Redacted Product.Price mapping on filter' (#50) from midrandshop into master
Reviewed-on: #50
2026-05-30 18:49:38 +02:00
khwezi 1c3f3eaf0d Merge pull request 'Added a way to get the Author by productId' (#49) from midrandshop into master
Reviewed-on: #49
2026-05-30 18:20:21 +02:00
khwezi 2e77666d9e Merge pull request 'Added category seeder' (#48) from midrandshop into master
Reviewed-on: #48
2026-05-30 16:08:11 +02:00
khwezi 1977b6b301 Merge pull request 'midrandshop' (#47) from midrandshop into master
Reviewed-on: #47
2026-05-30 15:37:09 +02:00
khwezi 0ab14d8b63 Merge pull request 'midrandshop' (#46) from midrandshop into master
Reviewed-on: #46
2026-05-30 00:14:10 +02:00
khwezi 466458e230 Merge pull request 'Implemented Product Data Seeder' (#45) from midrandshop into master
Reviewed-on: #45
2026-05-29 18:56:45 +02:00
khwezi 141d32f591 Merge pull request 'Refactored migrations' (#44) from midrandshop into master
Reviewed-on: #44
2026-05-29 09:03:27 +02:00
khwezi d9e7f225ae Merge pull request 'midrandshop' (#43) from midrandshop into master
Reviewed-on: #43
2026-05-29 08:23:15 +02:00
khwezi 6ae63e2ad1 Merge pull request 'midrandshop' (#42) from midrandshop into master
Reviewed-on: #42
2026-05-27 09:15:44 +02:00
khwezi f5efdde37c Merge pull request 'Refactored connection string name' (#41) from midrandshop into master
Reviewed-on: #41
2026-05-24 13:51:11 +02:00
khwezi 1592d5dc8f Merge pull request 'midrandshop' (#40) from midrandshop into master
Reviewed-on: #40
2026-05-24 13:43:22 +02:00
khwezi 50a8a59d92 Merge pull request 'Split Features to create space for more projects' (#39) from midrandshop into master
Reviewed-on: #39
2026-05-24 13:19:55 +02:00
khwezi b70d9559b0 Merge pull request 'midrandshop' (#38) from midrandshop into master
Reviewed-on: #38
2026-05-23 11:55:02 +02:00
khwezi 81d5e8f07c Merge pull request 'Added MidrandShop feature and spl;it extensions and healthchecks' (#37) from midrandshop into master
Reviewed-on: #37
2026-05-23 11:49:27 +02:00
khwezi 20a53942b5 Merge pull request 'Added product metadata' (#36) from s3service into master
Reviewed-on: #36
2026-05-20 21:15:01 +02:00
khwezi 9edb2aa4aa Merge pull request 'Optimised UploadFileAsync()' (#35) from s3service into master
Reviewed-on: #35
2026-05-20 15:33:47 +02:00
khwezi 6ed023f2cf Merge pull request 'Refactored the S3 services to properly upload the file' (#34) from s3service into master
Reviewed-on: #34
2026-05-20 08:03:43 +02:00
khwezi 2c9f5a846c Merge pull request 'Updated how i use configs' (#33) from s3service into master
Reviewed-on: #33
2026-05-19 14:57:59 +02:00
khwezi 41f7c05be3 Merge pull request 'Refactored service to internalise the CDN' (#32) from s3service into master
Reviewed-on: #32
2026-05-19 11:34:51 +02:00
khwezi 1a03355e84 Merge pull request 'Added S3 support' (#31) from s3service into master
Reviewed-on: #31
2026-05-19 10:24:05 +02:00
khwezi 7743c3178e Merge pull request 'Simplified notification updating' (#30) from emailjobs into master
Reviewed-on: #30
2026-05-17 16:01:24 +02:00
khwezi ab3d8e6e9a Merge pull request 'Refactored GetNotificationsAsync() date handling' (#29) from emailjobs into master
Reviewed-on: #29
2026-05-17 13:14:01 +02:00
khwezi db4c348288 Merge pull request 'Fixed email sending logic' (#28) from emailjobs into master
Reviewed-on: #28
2026-05-16 00:29:01 +02:00
khwezi 6683234642 Merge pull request 'Refactored batch drop logic' (#27) from emailjobs into master
Reviewed-on: #27
2026-05-16 00:05:51 +02:00
khwezi 6ddbb9479a Merge pull request 'Added an empty constructor to ProcessEmailNotificationEvent' (#26) from emailjobs into master
Reviewed-on: #26
2026-05-15 23:53:09 +02:00
khwezi 6c7349a0f8 Merge pull request 'Added additional logging and traces' (#25) from emailjobs into master
Reviewed-on: #25
2026-05-15 23:21:52 +02:00
khwezi e97fd6cd3f Merge pull request 'Added debug logging' (#24) from emailjobs into master
Reviewed-on: #24
2026-05-15 23:09:21 +02:00
khwezi 184c7c252a Merge pull request 'Set misfireThreshold to 2min and eased Cluster node checkin limit' (#23) from emailjobs into master
Reviewed-on: #23
2026-05-15 22:29:08 +02:00
khwezi bfe8c458d6 Merge pull request 'Optimised quartz' (#22) from emailjobs into master
Reviewed-on: #22
2026-05-15 09:52:06 +02:00
khwezi e6e0475db1 Merge pull request 'emailjobs' (#21) from emailjobs into master
Reviewed-on: #21
2026-05-15 08:39:36 +02:00
khwezi 5090c60797 Merge pull request 'Fixed Lead->Customer Relationship' (#20) from emailjobs into master
Reviewed-on: #20
2026-05-15 07:55:56 +02:00
khwezi 9432252e15 Merge pull request 'Added khongisa host entry on pipeline commands' (#19) from emailjobs into master
Reviewed-on: #19
2026-05-14 02:54:00 +02:00
khwezi 47111a1a3a Merge pull request 'emailjobs' (#18) from emailjobs into master
Reviewed-on: #18
2026-05-14 02:49:34 +02:00
khwezi 6eb3d50375 Merge pull request 'Updated job scheduler' (#17) from emailjobs into master
Reviewed-on: #17
2026-05-10 17:33:23 +02:00
khwezi 4deb732804 Merge pull request 'emailjobs' (#16) from emailjobs into master
Reviewed-on: #16
2026-05-10 16:51:24 +02:00
khwezi 20d9387d0b Merge pull request 'Migrated database changes' (#15) from develop into master
Reviewed-on: #15
2026-05-10 11:17:54 +02:00
khwezi 9f6d0ccaa0 Merge pull request 'Populated README' (#14) from develop into master
Reviewed-on: #14
2026-05-10 09:48:35 +02:00
khwezi 1acbc4d213 Merge pull request 'fixed git repo naming' (#13) from develop into master
Reviewed-on: #13
2026-05-10 09:22:37 +02:00
khwezi 8c99668fac Merge pull request 'Added tag and release step after publish' (#12) from develop into master
Reviewed-on: #12
2026-05-10 09:19:33 +02:00
khwezi ad44f46204 Merge pull request 'Refactored Quartz instance id to AUTO, removed constant' (#11) from develop into master
Reviewed-on: #11
2026-05-10 08:21:08 +02:00
khwezi 49d999c1e3 Merge pull request 'Refactored postgres extension' (#10) from develop into master
Reviewed-on: #10
2026-05-09 17:45:00 +02:00
khwezi 9ed4777a18 Merge pull request 'Refactored database references' (#9) from develop into master
Reviewed-on: #9
2026-05-09 17:01:02 +02:00
khwezi 0cf44f68cc Merge pull request 'Added scheduler constants' (#8) from develop into master
Reviewed-on: #8
2026-05-09 15:27:50 +02:00
khwezi 41ed5a4288 Merge pull request 'Fixed quartz host config table prefix' (#7) from develop into master
Reviewed-on: #7
2026-05-09 13:30:33 +02:00
khwezi bbcba5e06c Merge pull request 'Fixed quartz table name prefix' (#6) from develop into master
Reviewed-on: #6
2026-05-09 13:28:29 +02:00
khwezi 502cc326dd Merge pull request 'Updated pipeline to use major version with minor always 0' (#5) from develop into master
Reviewed-on: #5
2026-05-09 12:02:19 +02:00
khwezi 4675d4c5fc Merge pull request 'Updated pipeline to use major versions only' (#4) from develop into master
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
Reviewed-on: #4
2026-05-09 11:56:20 +02:00
khwezi f80bb2fff9 Merge pull request 'Changed target branch from nain to master' (#3) from dronepipeline into master
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
Reviewed-on: #3
2026-05-09 11:37:39 +02:00
khwezi 6767906b0d Merge pull request 'Added pipeline separator' (#2) from dronepipeline into master
Reviewed-on: #2
2026-05-09 11:25:34 +02:00
khwezi a344af4498 Merge pull request 'Added .drone.yml pipeline' (#1) from dronepipeline into master
Reviewed-on: #1
2026-05-09 11:12:38 +02:00
22 changed files with 1537 additions and 51 deletions
@@ -3,7 +3,7 @@ using LiteCharms.Features.MidrandBooks.Products;
namespace LiteCharms.Features.MidrandBooks.Seed; namespace LiteCharms.Features.MidrandBooks.Seed;
public class CategorySeederService(CategoryService categoryService, ProductService productService, IFeatureManager features, public sealed class CategorySeederService(CategoryService categoryService, ProductService productService, IFeatureManager features,
ILogger<CategorySeederService> logger) : BackgroundService ILogger<CategorySeederService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.MidrandBooks.Seed.Configuration; namespace LiteCharms.Features.MidrandBooks.Seed.Configuration;
public class CdnSettings public sealed class CdnSettings
{ {
public string? BaseCdn { get; set; } public string? BaseCdn { get; set; }
@@ -5,7 +5,7 @@ using LiteCharms.Features.MidrandBooks.Orders.Models;
namespace LiteCharms.Features.MidrandBooks.Seed; namespace LiteCharms.Features.MidrandBooks.Seed;
public class CustomerSeederService(CustomerService customerService, OrderService orderService, IFeatureManager features, public sealed class CustomerSeederService(CustomerService customerService, OrderService orderService, IFeatureManager features,
ILogger<CustomerSeederService> logger) : BackgroundService ILogger<CustomerSeederService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -5,7 +5,7 @@ using LiteCharms.Features.MidrandBooks.Seed.Configuration;
namespace LiteCharms.Features.MidrandBooks.Seed; namespace LiteCharms.Features.MidrandBooks.Seed;
public class ProductsSeederService(ProductService productService, AuthorService authorService, BooksService booksService, public sealed class ProductsSeederService(ProductService productService, AuthorService authorService, BooksService booksService,
IFeatureManager features, IOptions<CdnSettings> options, ILogger<ProductsSeederService> logger) : BackgroundService IFeatureManager features, IOptions<CdnSettings> options, ILogger<ProductsSeederService> logger) : BackgroundService
{ {
private readonly CdnSettings cdnSettings = options.Value; private readonly CdnSettings cdnSettings = options.Value;
@@ -5,7 +5,7 @@ using LiteCharms.Features.Models;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
public class AuthorServiceFeatureTests(Fixture fixture, ITestOutputHelper output) : IClassFixture<Fixture> public class AuthorServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{ {
private readonly AuthorService authorService = fixture.Services.GetRequiredService<AuthorService>(); private readonly AuthorService authorService = fixture.Services.GetRequiredService<AuthorService>();
@@ -1,5 +1,4 @@
using LiteCharms.Features.Extensions; using LiteCharms.Features.Extensions;
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Extensions;
namespace LiteCharms.Features.MidrandBooks.Tests.Common; namespace LiteCharms.Features.MidrandBooks.Tests.Common;
@@ -46,7 +46,6 @@ public static class Mappers
MerchantId = entity.MerchantId, MerchantId = entity.MerchantId,
MerchantKey = entity.MerchantKey, MerchantKey = entity.MerchantKey,
Name = entity.Name, Name = entity.Name,
Passphrase = entity.Passphrase,
Website = entity.Website, Website = entity.Website,
}; };
@@ -15,6 +15,5 @@ public sealed class PaymentGatewayConfiguration : IEntityTypeConfiguration<Payme
builder.Property(f => f.MerchantId).IsRequired(); builder.Property(f => f.MerchantId).IsRequired();
builder.Property(f => f.Enabled); builder.Property(f => f.Enabled);
builder.Property(f => f.Name).IsRequired(); builder.Property(f => f.Name).IsRequired();
builder.Property(f => f.Passphrase).IsRequired();
} }
} }
@@ -16,8 +16,6 @@ public class PaymentGateway
public string? MerchantKey { get; set; } public string? MerchantKey { get; set; }
public string? Passphrase { get; set; }
public bool IsSandbox { get; set; } public bool IsSandbox { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
@@ -174,7 +174,6 @@ public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> cont
MerchantKey = request.MerchantKey, MerchantKey = request.MerchantKey,
Name = request.Name, Name = request.Name,
Website = request.Website, Website = request.Website,
Passphrase = "N/A",
}); });
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class RemovedPassphraseFromPaymentGateway : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Passphrase",
table: "Gateways");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Passphrase",
table: "Gateways",
type: "text",
nullable: false,
defaultValue: "");
}
}
}
@@ -604,10 +604,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Passphrase")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -1,9 +1,8 @@
using LiteCharms.Features.Extensions; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Models; using LiteCharms.Features.Models;
using LiteCharms.Features.TechShop.Extensions; using LiteCharms.Features.TechShop.Extensions;
using LiteCharms.Features.TechShop.Leads.Models; using LiteCharms.Features.TechShop.Leads.Models;
using LiteCharms.Features.TechShop.Postgres; using LiteCharms.Features.TechShop.Postgres;
using static LiteCharms.Features.Extensions.Hash;
namespace LiteCharms.Features.TechShop.Leads; namespace LiteCharms.Features.TechShop.Leads;
@@ -29,7 +28,7 @@ public class LeadService(IDbContextFactory<ShopDbContext> contextFactory)
FeedItemId = request.FeedItemId, FeedItemId = request.FeedItemId,
Status = LeadStatus.New, Status = LeadStatus.New,
TargetId = request.TargetId, TargetId = request.TargetId,
AttributionHash = StringToSha256Hash.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}") AttributionHash = HashService.StringToSha256Hash($"{request.ClickId}{request.AppClickId}{request.WebClickId}")
}); });
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
+1
View File
@@ -26,6 +26,7 @@ public class Fixture : IDisposable
.AddGarageS3(Configuration) .AddGarageS3(Configuration)
.AddEmailServices(Configuration) .AddEmailServices(Configuration)
.AddSingleton(Configuration) .AddSingleton(Configuration)
.AddHashServices(Configuration)
.BuildServiceProvider(); .BuildServiceProvider();
Mediator = Services.GetRequiredService<IMediator>(); Mediator = Services.GetRequiredService<IMediator>();
@@ -0,0 +1,155 @@
using LiteCharms.Features.Hasher;
using LiteCharms.Features.Models;
using static LiteCharms.Features.Extensions.Hash;
namespace LiteCharms.Features.Tests;
public class HashServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly HashService hashService = fixture.Services.GetRequiredService<HashService>();
private readonly string payfastPassphrase = fixture.Configuration.GetSection("HasherSettings:PayfastPassphrase").Value!;
[Fact]
public void StringToSha256Hash_Should_GenerateHash()
{
var input = "We are the best";
var expectedHash = "96E17275B53F6BEB7A0D1C4F789F226D3C71CBE398585F25B3028F2B432E78AB";
var result = HashService.StringToSha256Hash(input);
Assert.NotNull(result);
Assert.True(HashService.IsSha256Hash(result));
Assert.Equal(expectedHash, result);
}
[Fact]
public void StreamToSha256Hash_Should_GenerateHash()
{
var input = "We are successful";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(input));
var expectedHash = "C27872EE494B09D72203C98FC858268F3CD3492D62AA7B766A873520C2C73AFB";
var result = HashService.StreamToSha256Hash(stream);
Assert.NotNull(result);
Assert.True(HashService.IsSha256Hash(result));
Assert.Equal(expectedHash, result);
}
[Fact]
public void BytesToSha256Hash_Should_GenerateHash()
{
var inputBytes = Encoding.UTF8.GetBytes("We are wealthy");
var expectedHash = "3876BF98F6E4A8E42B22C40415687D6FF13F0E887F3F508B71852298FC665737";
var result = HashService.BytesToSha256Hash(inputBytes);
Assert.NotNull(result);
Assert.True(HashService.IsSha256Hash(result));
Assert.Equal(expectedHash, result);
}
[Fact]
public void ToMd5Hash_Should_GenerateHash()
{
var input = "We manifest our desired destiny";
var expectedMd5Lowercase = "6c7816869bcebe4634f7afe9c66dfa08";
var result = HashService.ToMd5Hash(input);
Assert.True(result.IsSuccess);
Assert.True(HashService.IsMd5Hash(result.Value));
Assert.Equal(expectedMd5Lowercase, result.Value);
}
[Fact]
public void VerifyPayfastWebhookSignature_Should_GenerateHash()
{
var paymentId = hashService.HashEncodeLongId(1001).Value;
var payload = new PayfastWebhookPayload
{
Amount = "350.00",
ItemName = "System Architecture Book",
MPaymentId = paymentId,
};
var rawPayload = payload.ToRawPayfastPayload(payfastPassphrase);
var generatedSignature = HashService.ToMd5Hash(rawPayload).Value;
var result = hashService.VerifyPayfastWebhookSignature(payload, generatedSignature);
Assert.True(result.IsSuccess);
Assert.True(result.Value);
}
[Fact]
public void HashEncodeHex_Should_GenerateHash()
{
var validHexInput = "DEADBEEF42";
var result = hashService.HashEncodeHex(validHexInput);
Assert.True(result.IsSuccess);
Assert.False(string.IsNullOrWhiteSpace(result.Value));
}
[Fact]
public void HashEncodeIntId_Should_GenerateHash()
{
int targetId = 42;
var result = hashService.HashEncodeIntId(targetId);
Assert.True(result.IsSuccess);
Assert.True(result.Value.Length >= 10);
}
[Fact]
public void HashEncodeLongId_Should_GenerateHash()
{
long targetId = 9904185012L;
var result = hashService.HashEncodeLongId(targetId);
Assert.True(result.IsSuccess);
Assert.True(result.Value.Length >= 10);
}
[Fact]
public void DecodeIntIdHash_Should_GenerateHash()
{
int originalId = 88041;
var hashedString = hashService.HashEncodeIntId(originalId).Value;
var result = hashService.DecodeIntIdHash(hashedString);
Assert.True(result.IsSuccess);
Assert.Equal(originalId, result.Value);
}
[Fact]
public void DecodeLongIdHash_Should_GenerateHash()
{
long originalId = 9081230491823L;
var hashedString = hashService.HashEncodeLongId(originalId).Value;
var result = hashService.DecodeLongIdHash(hashedString);
Assert.True(result.IsSuccess);
Assert.Equal(originalId, result.Value);
}
[Fact]
public void DecodeHexHash_Should_GenerateHash()
{
var originalHex = "ABCDEF12345";
var hashedString = hashService.HashEncodeHex(originalHex).Value;
var result = hashService.DecodeHexHash(hashedString);
Assert.True(result.IsSuccess);
Assert.Equal(originalHex, result.Value);
}
}
@@ -27,6 +27,8 @@
<!-- Global Usings --> <!-- Global Usings -->
<ItemGroup> <ItemGroup>
<Using Include="System.Net" />
<Using Include="System.Text" />
<Using Include="Mediator" /> <Using Include="Mediator" />
<Using Include="Xunit.Abstractions" /> <Using Include="Xunit.Abstractions" />
<Using Include="Microsoft.Extensions.DependencyInjection" /> <Using Include="Microsoft.Extensions.DependencyInjection" />
+62
View File
@@ -1,5 +1,6 @@
using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Hasher.Configuration; using LiteCharms.Features.Hasher.Configuration;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.Extensions; namespace LiteCharms.Features.Extensions;
@@ -20,4 +21,65 @@ public static class Hash
return services; return services;
} }
public static string ToRawPayfastPayload(this PayfastWebhookPayload input, string passphrase)
{
var parameters = new List<string>();
if (!string.IsNullOrWhiteSpace(input.Amount))
parameters.Add($"amount={WebUtility.UrlEncode(input.Amount)}");
if (!string.IsNullOrWhiteSpace(input.ItemName))
parameters.Add($"item_name={WebUtility.UrlEncode(input.ItemName)}");
if (!string.IsNullOrWhiteSpace(input.MPaymentId))
parameters.Add($"m_payment_id={WebUtility.UrlEncode(input.MPaymentId)}");
string payload = string.Join("&", parameters);
if (!string.IsNullOrWhiteSpace(passphrase))
payload += $"&passphrase={WebUtility.UrlEncode(passphrase)}";
return payload;
}
public static (PayfastWebhookPayload Payload, string Passphrase) FromRawPayfastPayload(this string rawPayload)
{
string passphrase = string.Empty;
var payload = new PayfastWebhookPayload();
if (string.IsNullOrWhiteSpace(rawPayload)) return (payload, passphrase);
var segments = rawPayload.Split('&', StringSplitOptions.RemoveEmptyEntries);
foreach (var segment in segments)
{
int delimiterIndex = segment.IndexOf('=');
if (delimiterIndex == -1)
continue;
string key = segment[..delimiterIndex].Trim();
string rawValue = segment[(delimiterIndex + 1)..];
string decodedValue = WebUtility.UrlDecode(rawValue);
switch (key.ToLowerInvariant())
{
case "amount":
payload.Amount = decodedValue;
break;
case "item_name":
payload.ItemName = decodedValue;
break;
case "m_payment_id":
payload.MPaymentId = decodedValue;
break;
case "passphrase":
passphrase = decodedValue;
break;
}
}
return (payload, passphrase);
}
} }
+40 -21
View File
@@ -1,5 +1,6 @@
using LiteCharms.Features.Abstractions; using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Hasher.Configuration; using LiteCharms.Features.Hasher.Configuration;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.Hasher; namespace LiteCharms.Features.Hasher;
@@ -7,45 +8,63 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
{ {
private readonly HasherSettings settings = options.Value; private readonly HasherSettings settings = options.Value;
[System.Text.RegularExpressions.GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")] [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")]
private static partial System.Text.RegularExpressions.Regex HexHashRegex(); private static partial Regex HexHashRegex { get; }
public static readonly Func<string?, string?> StringToSha256Hash = (input) => [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)]
string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); private static partial Regex Md5Regex { get; }
public static readonly Func<Stream, string?> StreamToSha256Hash = (stream) => [GeneratedRegex(@"\A[0-9a-fA-F]{64}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)]
private static partial Regex Sha256Regex { get; }
public static bool IsMd5Hash(string? value) =>
!string.IsNullOrWhiteSpace(value) && Md5Regex.IsMatch(value);
public static bool IsSha256Hash(string? value) =>
!string.IsNullOrWhiteSpace(value) && Sha256Regex.IsMatch(value);
public static string? StringToSha256Hash(string? input) =>
string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
public static string? StreamToSha256Hash(Stream stream) =>
stream is null ? null : Convert.ToHexString(SHA256.HashData(stream)); stream is null ? null : Convert.ToHexString(SHA256.HashData(stream));
public static readonly Func<byte[], string?> BytesToSha256Hash = (bytes) => public static string? BytesToSha256Hash(byte[] bytes) =>
bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes)); bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes));
public static Result<string> ComputeMd5Hash(string input) public static Result<string> ToMd5Hash(string input)
{ {
if (string.IsNullOrEmpty(input)) if (string.IsNullOrEmpty(input))
return Result.Fail<string>("Input content cannot be null or empty for MD5 processing."); return Result.Fail<string>("Input content cannot be null or empty for MD5 processing.");
byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant());
} }
public Result<bool> VerifyPayfastWebhookSignature(IDictionary<string, string> incomingFormData, string incomingSignature) public Result<bool> VerifyPayfastWebhookSignature(PayfastWebhookPayload payload, string incomingSignature)
{ {
try try
{ {
if (string.IsNullOrWhiteSpace(incomingSignature)) if (string.IsNullOrWhiteSpace(incomingSignature))
return Result.Fail<bool>("Validation failed: Missing signature string parameter."); return Result.Fail<bool>("Validation failed: Missing signature string parameter.");
var sortedFields = incomingFormData var parameters = new List<string>();
.Where(field => field.Key != "signature")
.OrderBy(field => field.Key)
.Select(field => $"{field.Key}={Uri.EscapeDataString(field.Value).Replace("%20", "+")}");
string payload = string.Join("&", sortedFields); if (!string.IsNullOrWhiteSpace(payload.Amount))
parameters.Add($"amount={WebUtility.UrlEncode(payload.Amount)}");
if (!string.IsNullOrWhiteSpace(payload.ItemName))
parameters.Add($"item_name={WebUtility.UrlEncode(payload.ItemName)}");
if (!string.IsNullOrWhiteSpace(payload.MPaymentId))
parameters.Add($"m_payment_id={WebUtility.UrlEncode(payload.MPaymentId)}");
string signatureString = string.Join("&", parameters);
if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase)) if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase))
payload += $"&passphrase={Uri.EscapeDataString(settings.PayfastPassphrase).Replace("%20", "+")}"; signatureString += $"&passphrase={WebUtility.UrlEncode(settings.PayfastPassphrase)}";
var localHashResult = ComputeMd5Hash(payload); var localHashResult = ToMd5Hash(signatureString);
if (!localHashResult.IsSuccess) if (!localHashResult.IsSuccess)
return Result.Fail<bool>(localHashResult.Errors); return Result.Fail<bool>(localHashResult.Errors);
@@ -56,13 +75,13 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Fail<bool>(new Error("An error occurred during MD5 verification loop.").CausedBy(ex)); return Result.Fail<bool>(new Error("An error occurred during Payfast MD5 verification.").CausedBy(ex));
} }
} }
public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex().IsMatch(input) public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input)
? Result.Fail<string>("Input must be a valid hexadecimal string.") ? Result.Fail<string>("Input must be a valid hexadecimal string.")
: Result.Ok(hasher.EncodeHex(input)); : Result.Ok(hasher.EncodeHex(input));
public Result<string> HashEncodeIntId(int id) => id < 0 public Result<string> HashEncodeIntId(int id) => id < 0
? Result.Fail<string>("Id cannot be negative.") ? Result.Fail<string>("Id cannot be negative.")
@@ -148,6 +148,8 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="HashidsNet" /> <Using Include="HashidsNet" />
<Using Include="System.Net" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Globalization" /> <Using Include="System.Globalization" />
<Using Include="Microsoft.AspNetCore.Builder" /> <Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" /> <Using Include="Microsoft.Extensions.Hosting" />
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.Models;
public sealed class PayfastWebhookPayload
{
public string? Amount { get; set; }
public string? ItemName { get; set; }
public string? MPaymentId { get; set; }
}
-12
View File
@@ -1,12 +0,0 @@
namespace LiteCharms.Features.Models;
public class SearchState
{
public string Query { get; private set; } = string.Empty;
public event Action? OnSearchSubmitted;
public void UpdateQuery(string newQuery) => Query = newQuery;
public void SubmitSearch() => OnSearchSubmitted?.Invoke();
}