Add 'backend/' from commit '040cfd7a75423d4e6136e58a67b40579af4ee966'
git-subtree-dir: backend git-subtree-mainline:ab911955edgit-subtree-split:040cfd7a75
This commit is contained in:
147
backend/src/Web/Common/BlobStorage/AzureBlobStorage.cs
Normal file
147
backend/src/Web/Common/BlobStorage/AzureBlobStorage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
backend/src/Web/Common/BlobStorage/BlobStructure.txt
Normal file
33
backend/src/Web/Common/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
|
||||
│ │ └── ...
|
||||
│
|
||||
└── ...
|
||||
7
backend/src/Web/Common/BlobStorage/CommonFileNames.cs
Normal file
7
backend/src/Web/Common/BlobStorage/CommonFileNames.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Web.Common.BlobStorage;
|
||||
|
||||
public static class CommonFileNames
|
||||
{
|
||||
public static string ProfilePicture = "profilePicture";
|
||||
public static string BannerPicture = "bannerPicture";
|
||||
}
|
||||
7
backend/src/Web/Common/BlobStorage/ContainerNames.cs
Normal file
7
backend/src/Web/Common/BlobStorage/ContainerNames.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Web.Common.BlobStorage;
|
||||
|
||||
public static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Creators = "creators";
|
||||
}
|
||||
49
backend/src/Web/Common/BlobStorage/ContentTypes.cs
Normal file
49
backend/src/Web/Common/BlobStorage/ContentTypes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
7
backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs
Normal file
7
backend/src/Web/Common/BlobStorage/SubDirectoryNames.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Web.Common.BlobStorage;
|
||||
|
||||
public static class SubDirectoryNames
|
||||
{
|
||||
public static string Profile = "profile";
|
||||
public static string Contents = "contents";
|
||||
}
|
||||
94
backend/src/Web/Common/GuidExtensions.cs
Normal file
94
backend/src/Web/Common/GuidExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
63
backend/src/Web/Common/Security/ClaimsPrincipalExtensions.cs
Normal file
63
backend/src/Web/Common/Security/ClaimsPrincipalExtensions.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
53
backend/src/Web/Common/Security/GenerateJwtToken.cs
Normal file
53
backend/src/Web/Common/Security/GenerateJwtToken.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
backend/src/Web/Common/Security/KnownClaims.cs
Normal file
7
backend/src/Web/Common/Security/KnownClaims.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Hutopy.Web.Common.Security;
|
||||
|
||||
public static class KnownClaims
|
||||
{
|
||||
public const string Alias = "alias";
|
||||
public const string PortraitUrl = "portraitUrl";
|
||||
}
|
||||
5
backend/src/Web/Common/Security/MissingClaimException.cs
Normal file
5
backend/src/Web/Common/Security/MissingClaimException.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Hutopy.Web.Common.Security;
|
||||
|
||||
public class MissingClaimException(
|
||||
string claimName)
|
||||
: Exception;
|
||||
70
backend/src/Web/Common/Security/PasswordGenerator.cs
Normal file
70
backend/src/Web/Common/Security/PasswordGenerator.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user