Add 'backend/' from commit '040cfd7a75423d4e6136e58a67b40579af4ee966'

git-subtree-dir: backend
git-subtree-mainline: ab911955ed
git-subtree-split: 040cfd7a75
This commit is contained in:
2025-01-15 15:24:30 -05:00
179 changed files with 14349 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
namespace Hutopy.Web.Common.BlobStorage;
public class AzureBlobStorage
{
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;
}
}
}

View 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
│ │ └── ...
└── ...

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.BlobStorage;
public static class CommonFileNames
{
public static string ProfilePicture = "profilePicture";
public static string BannerPicture = "bannerPicture";
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.BlobStorage;
public static class ContainerNames
{
public const string Users = "users";
public const string Creators = "creators";
}

View File

@@ -0,0 +1,49 @@
using System.Text;
namespace Hutopy.Web.Common.BlobStorage;
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);
if (content.Contains("<!DOCTYPE html>"))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.BlobStorage;
public static class SubDirectoryNames
{
public static string Profile = "profile";
public static string Contents = "contents";
}

View File

@@ -0,0 +1,94 @@
namespace Hutopy.Web.Common;
/// <summary>
/// Adapted from https://raw.githubusercontent.com/uuidjs/uuid/main/src/v7.ts.
/// to match the uuid v7 generated on the client
/// </summary>
public static class GuidHelper
{
private class V7State
{
public long Msecs { get; set; } = long.MinValue;
public int Seq { get; set; }
}
private static readonly V7State State = new();
private static readonly Random Random = new();
public static Guid GenerateUuidV7()
{
byte[] randomValues = new byte[16];
Random.NextBytes(randomValues);
UpdateV7State(
State,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
randomValues);
var values = V7Bytes(randomValues, State.Msecs, State.Seq);
return new Guid(values);
}
private static void UpdateV7State(V7State state, long now, byte[] randomBytes)
{
if (now > state.Msecs)
{
state.Seq = (randomBytes[6] << 23) | (randomBytes[7] << 16) | (randomBytes[8] << 8) | randomBytes[9];
state.Msecs = now;
}
else
{
state.Seq = (state.Seq + 1) | 0;
if (state.Seq == 0)
{
state.Msecs++;
}
}
}
private static byte[] V7Bytes(byte[] randomBytes, long? msecs = null, int? seq = null, byte[]? buf = null, int offset = 0)
{
if (buf == null)
{
buf = new byte[16];
offset = 0;
}
// Defaults
msecs ??= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
seq ??= ((randomBytes[6] & 0x7f) << 24) | (randomBytes[7] << 16) | (randomBytes[8] << 8) | randomBytes[9];
// byte 0-5: timestamp (48 bits)
buf[offset++] = (byte)((msecs.Value / 0x10000000000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x100000000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x1000000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x10000) & 0xff);
buf[offset++] = (byte)((msecs.Value / 0x100) & 0xff);
buf[offset++] = (byte)(msecs.Value & 0xff);
// byte 6: `version` (4 bits) | sequence bits 28-31 (4 bits)
buf[offset++] = (byte)(0x70 | ((seq.Value >> 28) & 0x0f));
// byte 7: sequence bits 20-27 (8 bits)
buf[offset++] = (byte)((seq.Value >> 20) & 0xff);
// byte 8: `variant` (2 bits) | sequence bits 14-19 (6 bits)
buf[offset++] = (byte)(0x80 | ((seq.Value >> 14) & 0x3f));
// byte 9: sequence bits 6-13 (8 bits)
buf[offset++] = (byte)((seq.Value >> 6) & 0xff);
// byte 10: sequence bits 0-5 (6 bits) | random (2 bits)
buf[offset++] = (byte)(((seq.Value << 2) & 0xff) | (randomBytes[10] & 0x03));
// bytes 11-15: random (40 bits)
buf[offset++] = randomBytes[11];
buf[offset++] = randomBytes[12];
buf[offset++] = randomBytes[13];
buf[offset++] = randomBytes[14];
buf[offset] = randomBytes[15];
return buf;
}
}

View File

@@ -0,0 +1,63 @@
using System.Security.Claims;
namespace Hutopy.Web.Common.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);
if (claim is null) return default;
return 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);
if (typeof(TValue) == typeof(Guid))
{
return Guid.Parse(claim.Value);
}
return Convert.ChangeType(claim.Value, typeof(TValue));
}
}

View File

@@ -0,0 +1,53 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Hutopy.Web.Common.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[]
{
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(KnownClaims.Alias, alias));
}
if (portraitUrl is not null)
{
claims.Add(new(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);
}
}

View File

@@ -0,0 +1,7 @@
namespace Hutopy.Web.Common.Security;
public static class KnownClaims
{
public const string Alias = "alias";
public const string PortraitUrl = "portraitUrl";
}

View File

@@ -0,0 +1,5 @@
namespace Hutopy.Web.Common.Security;
public class MissingClaimException(
string claimName)
: Exception;

View File

@@ -0,0 +1,70 @@
using System.Text;
namespace Hutopy.Web.Common.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 GeneratePassword(
int minLength,
int maxLength,
bool requireNumber = true,
bool requireCapital = true,
bool requireSpecialCharacter = true)
{
// Create pools based on the requirements
var characterPool = new StringBuilder(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
int length = Random.Next(minLength, maxLength + 1);
var password = new char[length];
// Ensure at least one character from each required category is included
int index = 0;
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 of the password
for (int i = index; i < length; i++)
{
password[i] = characterPool[Random.Next(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
}
}
}