fix: confirm email changes and enforce clean backend build
Some checks failed
deploy-socialize / deploy (push) Has been cancelled
deploy-socialize / image (push) Has been cancelled

This commit is contained in:
2026-05-07 14:39:22 -04:00
parent 9022fa7d93
commit 57abe57bc7
54 changed files with 974 additions and 206 deletions

View File

@@ -39,6 +39,6 @@ internal static class ContentTypes
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer);
return content.Contains("<!DOCTYPE html>");
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -14,6 +14,14 @@ internal sealed class LocalBlobStorage(
private const long MaxUploadSize = 10 * 1024 * 1024;
private const string ContentTypeMetadataSuffix = ".content-type";
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
private static readonly Action<ILogger, string, string, string, string, Exception?> LogUploadedFile =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
new EventId(1, nameof(UploadFileAsync)),
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]");
private readonly LocalBlobStorageOptions _options = options.Value;
public async Task<string> UploadFileAsync(
@@ -46,12 +54,7 @@ internal sealed class LocalBlobStorage(
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
string fileUri = BuildPublicUrl(relativePath);
logger.LogInformation(
"Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]",
blobName,
containerName,
contentType,
fileUri);
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
return fileUri;
}
@@ -106,7 +109,7 @@ internal sealed class LocalBlobStorage(
throw new InvalidOperationException("Blob storage: Blob paths must be relative.");
}
string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])];
string[] pathParts = [containerName, .. blobName.Split(PathSeparators)];
if (pathParts.Any(part => part is "" or "." or ".."))
{
throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments.");
@@ -135,7 +138,7 @@ internal sealed class LocalBlobStorage(
? "/api/storage"
: requestPath.Trim();
return normalized.StartsWith("/", StringComparison.Ordinal)
return normalized.StartsWith('/')
? normalized.TrimEnd('/')
: $"/{normalized.TrimEnd('/')}";
}

View File

@@ -5,14 +5,15 @@ namespace Socialize.Api.Infrastructure.Emailer.Services;
internal class LoggerEmailSender(ILogger<IEmailSender> logger)
: IEmailSender
{
private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
LoggerMessage.Define<string, string, string, string>(
LogLevel.Information,
new EventId(1, nameof(SendEmailAsync)),
"Development email to {Email} with subject {Subject}:{NewLine}{Message}");
public Task SendEmailAsync(string email, string subject, string message)
{
logger.LogInformation(
"Development email to {Email} with subject {Subject}:{NewLine}{Message}",
email,
subject,
Environment.NewLine,
message);
LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
return Task.CompletedTask;
}

View File

@@ -43,14 +43,13 @@ internal class ResendEmailSender : IEmailSender
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
public async Task SendEmailAsync(string email, string subject, string message)
{
var payload = new { from = _options.FromEmail, to = toEmail, subject, html = htmlMessage };
var payload = new { from = _options.FromEmail, to = email, subject, html = message };
string json = JsonSerializer.Serialize(payload);
StringContent content = new(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
using StringContent content = new(json, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
if (!response.IsSuccessStatusCode)
{

View File

@@ -7,49 +7,49 @@ namespace Socialize.Api.Infrastructure.Security;
internal sealed class AccessScopeService(
OrganizationAccessService organizationAccessService)
{
public bool IsManager(ClaimsPrincipal user)
public static bool IsManager(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
}
public bool IsProvider(ClaimsPrincipal user)
public static bool IsProvider(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Provider);
}
public bool IsClient(ClaimsPrincipal user)
public static bool IsClient(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Client);
}
public bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
{
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
}
public bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
{
return IsManager(user) && CanAccessWorkspace(user, workspaceId);
}
public bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{
return IsManager(user)
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
}
public bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
}
public bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
}
public bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)

View File

@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Globalization;
using System.Security.Claims;
namespace Socialize.Api.Infrastructure.Security;
@@ -81,11 +82,11 @@ internal static class ClaimsPrincipalExtensions
if (claim is null)
{
throw new MissingClaimException(key);
throw MissingClaimException.ForClaim(key);
}
return typeof(TValue) == typeof(Guid)
? Guid.Parse(claim.Value)
: Convert.ChangeType(claim.Value, typeof(TValue));
: Convert.ChangeType(claim.Value, typeof(TValue), CultureInfo.InvariantCulture);
}
}

View File

@@ -1,5 +1,23 @@
namespace Socialize.Api.Infrastructure.Security;
internal class MissingClaimException(
string claimName)
: Exception($"Claim '{claimName}' is missing.");
public class MissingClaimException : Exception
{
public MissingClaimException()
{
}
public MissingClaimException(string message)
: base(message)
{
}
public MissingClaimException(string message, Exception innerException)
: base(message, innerException)
{
}
internal static MissingClaimException ForClaim(string claimName)
{
return new MissingClaimException($"Claim '{claimName}' is missing.");
}
}

View File

@@ -11,8 +11,6 @@ internal static class PasswordGenerator
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,
@@ -23,7 +21,7 @@ internal static class PasswordGenerator
// Create pools based on the requirements
StringBuilder characterPool = new();
if (requireNumber)
if (requireLowercase)
{
characterPool.Append(LowerLetters);
}
@@ -51,22 +49,22 @@ internal static class PasswordGenerator
if (requireLowercase)
{
password[index++] = LowerLetters[Random.Next(LowerLetters.Length)];
password[index++] = LowerLetters[RandomNumberGenerator.GetInt32(LowerLetters.Length)];
}
if (requireCapital)
{
password[index++] = UpperLetters[Random.Next(UpperLetters.Length)];
password[index++] = UpperLetters[RandomNumberGenerator.GetInt32(UpperLetters.Length)];
}
if (requireNumber)
{
password[index++] = Numbers[Random.Next(Numbers.Length)];
password[index++] = Numbers[RandomNumberGenerator.GetInt32(Numbers.Length)];
}
if (requireSpecialCharacter)
{
password[index++] = SpecialCharacters[Random.Next(SpecialCharacters.Length)];
password[index++] = SpecialCharacters[RandomNumberGenerator.GetInt32(SpecialCharacters.Length)];
}
// Fill the rest with the password
@@ -85,7 +83,7 @@ internal static class PasswordGenerator
{
for (int i = array.Length - 1; i > 0; i--)
{
int j = Random.Next(i + 1);
int j = RandomNumberGenerator.GetInt32(i + 1);
(array[i], array[j]) = (array[j], array[i]); // Swap elements
}
}

View File

@@ -19,6 +19,8 @@ using Microsoft.AspNetCore.Identity;
namespace Socialize.Api.Infrastructure.TestData;
#pragma warning disable S1075 // Test data intentionally uses representative external URLs.
internal static class TestDataSeedExtensions
{
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
@@ -209,13 +211,7 @@ internal static class TestDataSeedExtensions
await userManager.RemoveClaimAsync(user, claim);
}
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
? KnownRoles.Manager
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
? KnownRoles.Client
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
? KnownRoles.Provider
: KnownRoles.WorkspaceMember;
string persona = GetPersona(roles);
foreach (Claim claim in claims.Concat([new Claim(KnownClaims.Persona, persona)]))
{
@@ -225,6 +221,26 @@ internal static class TestDataSeedExtensions
return user;
}
private static string GetPersona(IReadOnlyCollection<string> roles)
{
if (roles.Contains(KnownRoles.Manager, StringComparer.Ordinal))
{
return KnownRoles.Manager;
}
if (roles.Contains(KnownRoles.Client, StringComparer.Ordinal))
{
return KnownRoles.Client;
}
if (roles.Contains(KnownRoles.Provider, StringComparer.Ordinal))
{
return KnownRoles.Provider;
}
return KnownRoles.WorkspaceMember;
}
private static async Task EnsureOrganizationDataAsync(
Guid managerUserId,
Guid developerUserId,