diff --git a/.gitignore b/.gitignore index 6f1536f5..a13a4b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ riderModule.iml .vs/ .DS_Store *.user + +src/Argon.Api/storage/ diff --git a/src/Argon.Api/Argon.Api.csproj b/src/Argon.Api/Argon.Api.csproj index 72f9d325..e4c8157a 100644 --- a/src/Argon.Api/Argon.Api.csproj +++ b/src/Argon.Api/Argon.Api.csproj @@ -13,6 +13,9 @@ + + + diff --git a/src/Argon.Api/Controllers/FilesController.cs b/src/Argon.Api/Controllers/FilesController.cs new file mode 100644 index 00000000..087f93b2 --- /dev/null +++ b/src/Argon.Api/Controllers/FilesController.cs @@ -0,0 +1,74 @@ +namespace Argon.Api.Controllers; + +using ActualLab.Collections; +using Argon.Api.Features.MediaStorage.Storages; +using Contracts; +using Features.MediaStorage; +using Features.Pex; +using Grains.Interfaces; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +public class FilesController( + IOptions cdnOptions, + IContentDeliveryNetwork cdn, + IPermissionProvider permissions, + IGrainFactory grainFactory) : ControllerBase +{ + // work only when cdn\storage set local disk or in memory + [HttpGet("/files/{nsPath}/{nsId:guid}/{kind}/{shard}/{fileId}")] + public async ValueTask Files( + [FromRoute] string nsPath, + [FromRoute] Guid nsId, + [FromRoute] string kind, + [FromRoute] string shard, + [FromRoute] string fileId) + { + if (cdnOptions.Value.Storage.Kind == StorageKind.GenericS3) + return BadRequest(); + + var ns = new StorageNameSpace(nsPath, nsId); + var assetId = AssetId.FromFileId(fileId); + var mem = DiskContentStorage.OpenFileRead(ns, assetId); + + return File(mem, assetId.GetMime()); + } + + [HttpPost("/files/server/{serverId:guid}/avatar"), Authorize(JwtBearerDefaults.AuthenticationScheme)] + public async ValueTask UploadServerAvatar([FromRoute] Guid serverId, IFormFile file) + { + // TODO + if (!permissions.CanAccess("server.avatar.upload", PropertyBag.Empty.Set(serverId))) + return StatusCode(401); + var assetId = AssetId.Avatar(); + var result = await cdn.CreateAssetAsync(StorageNameSpace.ForServer(serverId), assetId, file); + + if (result.HasValue) + return Ok(result); + + await grainFactory.GetGrain(serverId) + .UpdateServer(new ServerInput(null, null, assetId.ToFileId())); + + return Ok(); + } + + [HttpPost("/files/user/{userId:guid}/avatar"), Authorize(JwtBearerDefaults.AuthenticationScheme)] + public async ValueTask UploadUserAvatar([FromRoute] Guid userId, IFormFile file) + { + // TODO + if (!permissions.CanAccess("user.avatar.upload", PropertyBag.Empty.Set(userId))) + return StatusCode(401); + var assetId = AssetId.Avatar(); + var result = await cdn.CreateAssetAsync(StorageNameSpace.ForUser(userId), assetId, file); + + if (result.HasValue) + return Ok(result); + + await grainFactory.GetGrain(userId) + .UpdateUser(new UserEditInput(null, null, assetId.ToFileId())); + + return Ok(); + } +} \ No newline at end of file diff --git a/src/Argon.Api/Controllers/MetadataController.cs b/src/Argon.Api/Controllers/MetadataController.cs index dab215aa..3d399abf 100644 --- a/src/Argon.Api/Controllers/MetadataController.cs +++ b/src/Argon.Api/Controllers/MetadataController.cs @@ -1,5 +1,6 @@ namespace Argon.Api.Controllers; +using Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -7,24 +8,19 @@ namespace Argon.Api.Controllers; public class MetadataController : ControllerBase { [HttpGet("/cfg.json"), AllowAnonymous] - public ValueTask GetHead() => new(new HeadRoutingConfig($"{GlobalVersion.FullSemVer}.{GlobalVersion.ShortSha}", - "api.argon.gl", "argon-f14ic5ia.livekit.cloud", [ - new RegionalNode("cdn-ru1.argon.gl", "ru1"), - new RegionalNode("cdn-ru2.argon.gl", "ru1"), - new RegionalNode("cdn-as1.argon.gl", "as1") - ], [ - new FeatureFlag("dev.window", true), - new FeatureFlag("user.allowServerCreation", true) - ])); + public ValueTask GetHead() + => new(new HeadRoutingConfig($"{GlobalVersion.FullSemVer}.{GlobalVersion.ShortSha}", + "api.argon.gl", "argon-f14ic5ia.livekit.cloud", [ + new FeatureFlag("dev.window", true), + new FeatureFlag("user.allowServerCreation", true) + ], this.HttpContext.GetRegion())); } public record HeadRoutingConfig( string version, string masterEndpoint, string webRtcEndpoint, - List cdnAddresses, - List features); - -public record RegionalNode(string url, string code); + List features, + string currentRegionCode); public record FeatureFlag(string code, bool enabled); \ No newline at end of file diff --git a/src/Argon.Api/Extensions/HttpContextExtensions.cs b/src/Argon.Api/Extensions/HttpContextExtensions.cs index 7bdfb304..66cd10b7 100644 --- a/src/Argon.Api/Extensions/HttpContextExtensions.cs +++ b/src/Argon.Api/Extensions/HttpContextExtensions.cs @@ -12,6 +12,11 @@ public static string GetRegion(this HttpContext ctx) ? ctx.Request.Headers["CF-IPCountry"].ToString() : "unknown"; + public static string GetRay(this HttpContext ctx) + => ctx.Request.Headers.ContainsKey("CF-Ray") + ? ctx.Request.Headers["CF-Ray"].ToString() + : $"{Guid.NewGuid()}"; + public static string GetClientName(this HttpContext ctx) => ctx.Request.Headers.ContainsKey("User-Agent") ? ctx.Request.Headers["User-Agent"].ToString() diff --git a/src/Argon.Api/Features/Jwt/JwtFeature.cs b/src/Argon.Api/Features/Jwt/JwtFeature.cs index 438802c1..184b6f8b 100644 --- a/src/Argon.Api/Features/Jwt/JwtFeature.cs +++ b/src/Argon.Api/Features/Jwt/JwtFeature.cs @@ -1,5 +1,6 @@ namespace Argon.Api.Features.Jwt; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; public static class JwtFeature @@ -10,7 +11,7 @@ public static IServiceCollection AddJwt(this WebApplicationBuilder builder) var jwt = builder.Configuration.GetSection("Jwt").Get(); - builder.Services.AddSingleton(new TokenValidationParameters + var tokenValidator = new TokenValidationParameters { ValidIssuer = jwt.Issuer, ValidAudience = jwt.Audience, @@ -20,8 +21,18 @@ public static IServiceCollection AddJwt(this WebApplicationBuilder builder) ValidateLifetime = true, ValidateIssuerSigningKey = true, ClockSkew = TimeSpan.Zero - }); + }; + + builder.Services.AddSingleton(tokenValidator); builder.Services.AddSingleton(); + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer((options) => options.TokenValidationParameters = tokenValidator); + return builder.Services; } } \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/AssetId.cs b/src/Argon.Api/Features/MediaStorage/AssetId.cs new file mode 100644 index 00000000..bf23897d --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/AssetId.cs @@ -0,0 +1,73 @@ +namespace Argon.Api.Features.MediaStorage; + +public readonly struct AssetId(Guid assetId, AssetScope scope, AssetKind kind) +{ + public string ToFileId() + => $"{assetId:D}-{((byte)scope):X2}-{((byte)kind):X2}-00"; // last two zero reserved + + public string GetFilePath() + { + if (scope == AssetScope.ProfileAsset) + return $"profile/{assetId.ToString().Substring(0, 8)}/{ToFileId()}"; + if (scope == AssetScope.ChatAsset) + return $"chat/{assetId.ToString().Substring(0, 8)}/{ToFileId()}"; + if (scope == AssetScope.ServiceAsset) + return $"service/{assetId.ToString().Substring(0, 8)}/{ToFileId()}"; + return $"temp/{ToFileId()}"; + } + + public string GetMime() + => kind switch + { + AssetKind.Image => "image/png", + AssetKind.Video => "video/mp4", + AssetKind.VideoNoSound => "video/mp4", + AssetKind.File => "application/binary", + AssetKind.ServerContent => "application/binary", + AssetKind.ServiceContent => "application/binary", + AssetKind.Sound => "application/ogg", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + public Dictionary GetTags(StorageNameSpace @namespace) + { + var tags = new Dictionary + { + { nameof(AssetScope), $"{scope}" }, + { nameof(AssetKind), $"{kind}" }, + { $"Id", $"{assetId}" }, + { $"Namespace", $"{@namespace.path}:{@namespace.id}" } + }; + return tags; + } + public static AssetId FromFileId(string fileId) + { + if (fileId.Length != 45) + throw new InvalidOperationException("Bad file id"); + var span = fileId.AsSpan(); + var guid = Guid.Parse(span.Slice(0, 36)); + var scope = byte.Parse(span.Slice(37, 2)); + var kind = byte.Parse(span.Slice(40, 2)); + return new AssetId(guid, (AssetScope)scope, (AssetKind)kind); + } + + public static AssetId Avatar() => new(Guid.NewGuid(), AssetScope.ProfileAsset, AssetKind.Image); + public static AssetId VideoAvatar() => new(Guid.NewGuid(), AssetScope.ProfileAsset, AssetKind.VideoNoSound); +} + +public enum AssetScope : byte +{ + ProfileAsset, + ChatAsset, + ServiceAsset +} + +public enum AssetKind : byte +{ + Image, + Video, // only png + VideoNoSound, // gif + File, + ServerContent, + ServiceContent, + Sound +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/CdnFeatureExtensions.cs b/src/Argon.Api/Features/MediaStorage/CdnFeatureExtensions.cs new file mode 100644 index 00000000..e2ad39fa --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/CdnFeatureExtensions.cs @@ -0,0 +1,74 @@ +namespace Argon.Api.Features.MediaStorage; + +using Storages; +using Genbox.SimpleS3.Core.Abstracts.Clients; +using Genbox.SimpleS3.Core.Abstracts.Enums; +using Genbox.SimpleS3.Core.Common.Authentication; +using Genbox.SimpleS3.Core.Extensions; +using Genbox.SimpleS3.Extensions.GenericS3.Extensions; +using Genbox.SimpleS3.Core.Abstracts.Request; +using Genbox.SimpleS3.Extensions.HttpClient.Extensions; + +public static class CdnFeatureExtensions +{ + public static IServiceCollection AddContentDeliveryNetwork(this WebApplicationBuilder builder) + { + builder.Services.Configure(builder.Configuration.GetSection("Cdn")); + + var opt = builder.Configuration.GetSection("Cdn:Storage"); + var options = builder.Services.Configure(opt); + var bucketOptions = new StorageOptions(); + opt.Bind(bucketOptions); + + + + return bucketOptions.Kind switch + { + StorageKind.InMemory => throw new InvalidOperationException(), + StorageKind.Disk => builder.AddContentDeliveryNetwork(bucketOptions.Kind, bucketOptions), + StorageKind.GenericS3 => builder.AddContentDeliveryNetwork(bucketOptions.Kind, bucketOptions), + _ => throw new ArgumentOutOfRangeException() + }; + } + + public static IServiceCollection AddContentDeliveryNetwork(this WebApplicationBuilder builder, StorageKind keyName, StorageOptions options) + where T : class, IContentDeliveryNetwork + { + if (keyName == StorageKind.Disk) + { + builder.Services.AddKeyedSingleton(IContentStorage.DiskContentStorageKey); + builder.Services.AddSingleton(); + } + if (keyName == StorageKind.GenericS3) + { + builder.Services.AddKeyedSingleton(IContentStorage.GenericS3StorageKey); + builder.Services.AddSingleton(); + builder.AddS3Storage(keyName, options); + } + + return builder.Services; + } + + + public static IServiceCollection AddS3Storage(this WebApplicationBuilder builder, StorageKind keyName, StorageOptions options) + { + var storageContainer = new ServiceCollection(); + var coreBuilder = SimpleS3CoreServices.AddSimpleS3Core(storageContainer); + coreBuilder.UseHttpClient(); + coreBuilder.UseGenericS3(config => + { + config.Endpoint = options.BaseUrl; + config.RegionCode = options.Region; + config.Credentials = new StringAccessKey(options.Login, options.Password); + config.NamingMode = NamingMode.PathStyle; + }); + + var storageServices = storageContainer.BuildServiceProvider(); + + builder.Services.AddKeyedSingleton($"{keyName}:container", storageServices); + builder.Services.AddKeyedSingleton($"{keyName}:client", (services, o) + => storageServices.GetRequiredService()); + + return builder.Services; + } +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/CdnOptions.cs b/src/Argon.Api/Features/MediaStorage/CdnOptions.cs new file mode 100644 index 00000000..b02fcbd9 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/CdnOptions.cs @@ -0,0 +1,18 @@ +namespace Argon.Api.Features.MediaStorage; + +public class CdnOptions +{ + public string BaseUrl { get; set; } + public TimeSpan EntryExpire { get; set; } + public bool SignUrl { get; set; } + public string SignSecret { get; set; } + public StorageOptions Storage { get; set; } +} + +public readonly record struct StorageSpace(ulong total, ulong current, uint free); + +public enum UploadError +{ + NONE, + INTERNAL_ERROR +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/DiskContentDeliveryNetwork.cs b/src/Argon.Api/Features/MediaStorage/DiskContentDeliveryNetwork.cs new file mode 100644 index 00000000..1ecf57f6 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/DiskContentDeliveryNetwork.cs @@ -0,0 +1,28 @@ +namespace Argon.Api.Features.MediaStorage; + +using Contracts; + +public class DiskContentDeliveryNetwork([FromKeyedServices(IContentStorage.DiskContentStorageKey)] IContentStorage storage, + ILogger logger) : IContentDeliveryNetwork +{ + public IContentStorage Storage { get; } = storage; + public async ValueTask> CreateAssetAsync(StorageNameSpace ns, AssetId asset, Stream file) + { + try + { + await Storage.UploadFile(ns, asset, file); + return Maybe.None(); + } + catch (Exception e) + { + logger.LogCritical(e, $"Failed upload file '{asset.GetFilePath()}'"); + return UploadError.INTERNAL_ERROR; + } + } + + public ValueTask> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, Stream file) + => throw new NotImplementedException(); + + public ValueTask GenerateAssetUrl(StorageNameSpace ns, AssetId asset) + => new($"/files/{ns.ToPath()}/{asset.GetFilePath()}?nocache=1"); +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/IContentDeliveryNetwork.cs b/src/Argon.Api/Features/MediaStorage/IContentDeliveryNetwork.cs new file mode 100644 index 00000000..e94e78f6 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/IContentDeliveryNetwork.cs @@ -0,0 +1,24 @@ +namespace Argon.Api.Features.MediaStorage; + +using Contracts; + +public interface IContentDeliveryNetwork +{ + IContentStorage Storage { get; } + + ValueTask> CreateAssetAsync(StorageNameSpace ns, AssetId asset, IFormFile file) + { + var memory = file.OpenReadStream(); + return CreateAssetAsync(ns, asset, memory); + } + + ValueTask> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, IFormFile file) + { + using var memory = file.OpenReadStream(); + return ReplaceAssetAsync(ns, asset, memory); + } + + ValueTask> CreateAssetAsync(StorageNameSpace ns, AssetId asset, Stream file); + ValueTask> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, Stream file); + ValueTask GenerateAssetUrl(StorageNameSpace ns, AssetId asset); +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/IContentStorage.cs b/src/Argon.Api/Features/MediaStorage/IContentStorage.cs new file mode 100644 index 00000000..9274d7f6 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/IContentStorage.cs @@ -0,0 +1,42 @@ +namespace Argon.Api.Features.MediaStorage; + +public interface IContentStorage +{ + ValueTask GetStorageStats(); + + ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Stream data); + + ValueTask DeleteFile(StorageNameSpace block, AssetId assetId); + + + public const string GenericS3StorageKey = "cdn:bucket:s3"; + public const string InMemoryStorageKey = "cdn:bucket:inmemory"; + public const string DiskContentStorageKey = "cdn:bucket:disk"; +} + + +public record struct StorageNameSpace(string path, Guid id) +{ + public string ToPath() => $"{path}/{id:N}"; + + public static StorageNameSpace ForServer(Guid serverId) => new("servers", serverId); + public static StorageNameSpace ForUser(Guid userId) => new("users", userId); +} + + +public enum StorageKind +{ + InMemory, + Disk, + GenericS3 +} + +public class StorageOptions +{ + public StorageKind Kind { get; set; } + public string BaseUrl { get; set; } + public string Login { get; set; } + public string Region { get; set; } + public string Password { get; set; } + public string BucketName { get; set; } +} diff --git a/src/Argon.Api/Features/MediaStorage/Storages/DiskContentStorage.cs b/src/Argon.Api/Features/MediaStorage/Storages/DiskContentStorage.cs new file mode 100644 index 00000000..3556da76 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/Storages/DiskContentStorage.cs @@ -0,0 +1,29 @@ +namespace Argon.Api.Features.MediaStorage.Storages; + +public class DiskContentStorage : IContentStorage +{ + public ValueTask GetStorageStats() + => new(new StorageSpace(0, 0, 0)); + + public async ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Stream data) + { + var fullPath = $"./storage/{block.ToPath()}/{assetId.GetFilePath()}"; + var directory = new FileInfo(fullPath).Directory!; + + if (!directory.Exists) + directory.Create(); + + + await using var stream = File.OpenWrite(fullPath); + await data.CopyToAsync(stream); + } + + public async ValueTask DeleteFile(StorageNameSpace block, AssetId assetId) + { + if (File.Exists($"./storage/{block.ToPath()}/{assetId.GetFilePath()}")) + File.Delete($"./storage/{block.ToPath()}/{assetId.GetFilePath()}"); + } + + public static Stream OpenFileRead(StorageNameSpace block, AssetId assetId) + => File.OpenRead($"./storage/{block.ToPath()}/{assetId.GetFilePath()}"); +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/Storages/S3ContentStorage.cs b/src/Argon.Api/Features/MediaStorage/Storages/S3ContentStorage.cs new file mode 100644 index 00000000..52c97ca3 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/Storages/S3ContentStorage.cs @@ -0,0 +1,31 @@ +namespace Argon.Api.Features.MediaStorage.Storages; + +using Genbox.SimpleS3.Core.Abstracts.Clients; +using Microsoft.Extensions.Options; + +public class S3ContentStorage([FromKeyedServices("GenericS3:client")] IObjectClient s3Client, IOptions options) : IContentStorage +{ + public ValueTask GetStorageStats() + => new(new StorageSpace(0, 0, 0)); + + public async ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Stream data) + { + var config = options.Value; + var result = await s3Client.PutObjectAsync(config.BucketName, $"{block.ToPath()}/{assetId.GetFilePath()}", data, request => { + foreach (var (key, value) in assetId.GetTags(block)) + request.Tags.Add(key, value); + }); + + if (!result.IsSuccess) + throw new InvalidOperationException(); + } + + public async ValueTask DeleteFile(StorageNameSpace block, AssetId assetId) + { + var config = options.Value; + var result = await s3Client.DeleteObjectAsync(config.BucketName, $"/{block.ToPath()}/{assetId.GetFilePath()}"); + + if (!result.IsSuccess) + throw new InvalidOperationException(); + } +} \ No newline at end of file diff --git a/src/Argon.Api/Features/MediaStorage/YandexContentDeliveryNetwork.cs b/src/Argon.Api/Features/MediaStorage/YandexContentDeliveryNetwork.cs new file mode 100644 index 00000000..0f530445 --- /dev/null +++ b/src/Argon.Api/Features/MediaStorage/YandexContentDeliveryNetwork.cs @@ -0,0 +1,62 @@ +namespace Argon.Api.Features.MediaStorage; + +using System.Web; +using Contracts; +using Microsoft.Extensions.Options; + +public class YandexContentDeliveryNetwork([FromKeyedServices(IContentStorage.GenericS3StorageKey)] IContentStorage storage, + ILogger logger, IOptions options) + : IContentDeliveryNetwork +{ + public IContentStorage Storage { get; } = storage; + public CdnOptions Config => options.Value; + + public async ValueTask> CreateAssetAsync(StorageNameSpace ns, AssetId asset, Stream file) + { + try + { + await Storage.UploadFile(ns, asset, file); + return Maybe.None(); + } + catch (Exception e) + { + logger.LogCritical(e, $"Failed upload file '{asset.GetFilePath()}'"); + return UploadError.INTERNAL_ERROR; + } + } + + public ValueTask> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, Stream file) + => throw new NotImplementedException(); + + public ValueTask GenerateAssetUrl(StorageNameSpace ns, AssetId asset) + => new(GenerateSignedLink(Config.BaseUrl,$"/{ns.ToPath()}/{asset.GetFilePath()}", Config.SignSecret, (int)Config.EntryExpire.TotalSeconds)); + + private static string GenerateSignedLink( + string hostname, + string path, + string secret, + int expiryInSeconds, + string? ip = null) + { + var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expiryInSeconds; + var tokenData = expires + path + (ip ?? "") + secret; + + using var md5 = MD5.Create(); + var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(tokenData)); + var token = Convert.ToBase64String(hashBytes) + .Replace("\n", "") + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + + var query = HttpUtility.ParseQueryString(string.Empty); + query["md5"] = token; + query["expires"] = expires.ToString(); + if (!string.IsNullOrEmpty(ip)) + { + query["ip"] = ip; + } + + return $"{hostname}{path}?{query}"; + } +} \ No newline at end of file diff --git a/src/Argon.Api/Features/Orleanse/MemoryPackCodec.cs b/src/Argon.Api/Features/Orleanse/MemoryPackCodec.cs index b7aa9d2f..cff95b0f 100644 --- a/src/Argon.Api/Features/Orleanse/MemoryPackCodec.cs +++ b/src/Argon.Api/Features/Orleanse/MemoryPackCodec.cs @@ -163,7 +163,7 @@ void IFieldCodec.WriteField(ref Writer writer, uin var bufferWriter = new BufferWriterBox(new()); try { - MemoryPackSerializer.Serialize(bufferWriter, value); + MemoryPackSerializer.Serialize(value.GetType(), bufferWriter, value); ReferenceCodec.MarkValueField(writer.Session); writer.WriteFieldHeaderExpected(1, WireType.LengthPrefixed); diff --git a/src/Argon.Api/Features/Pex/IPermissionProvider.cs b/src/Argon.Api/Features/Pex/IPermissionProvider.cs new file mode 100644 index 00000000..473eb1ce --- /dev/null +++ b/src/Argon.Api/Features/Pex/IPermissionProvider.cs @@ -0,0 +1,25 @@ +namespace Argon.Api.Features.Pex; + +using ActualLab.Collections; + + +public static class PexFeature +{ + public static IServiceCollection AddArgonPermissions(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + return builder.Services; + } +} + + +public interface IPermissionProvider +{ + public bool CanAccess(string scope, PropertyBag bag); +} + +public class NullPermissionProvider : IPermissionProvider +{ + public bool CanAccess(string scope, PropertyBag bag) + => true; +} \ No newline at end of file diff --git a/src/Argon.Api/Grains.Interfaces/IServerGrain.cs b/src/Argon.Api/Grains.Interfaces/IServerGrain.cs index 2b695c76..a82dff43 100644 --- a/src/Argon.Api/Grains.Interfaces/IServerGrain.cs +++ b/src/Argon.Api/Grains.Interfaces/IServerGrain.cs @@ -27,7 +27,7 @@ public interface IServerGrain : IGrainWithGuidKey [DataContract, MemoryPackable(GenerateType.VersionTolerant), MessagePackObject, Serializable, GenerateSerializer, Alias(nameof(ServerInput))] public sealed partial record ServerInput( [property: DataMember(Order = 0), MemoryPackOrder(0), Key(0), Id(0)] - string Name, + string? Name, [property: DataMember(Order = 1), MemoryPackOrder(1), Key(1), Id(1)] string? Description, [property: DataMember(Order = 2), MemoryPackOrder(2), Key(2), Id(2)] diff --git a/src/Argon.Api/Grains.Interfaces/IUserManager.cs b/src/Argon.Api/Grains.Interfaces/IUserGrain.cs similarity index 70% rename from src/Argon.Api/Grains.Interfaces/IUserManager.cs rename to src/Argon.Api/Grains.Interfaces/IUserGrain.cs index 214e160b..31773018 100644 --- a/src/Argon.Api/Grains.Interfaces/IUserManager.cs +++ b/src/Argon.Api/Grains.Interfaces/IUserGrain.cs @@ -3,13 +3,14 @@ namespace Argon.Api.Grains.Interfaces; using Contracts; using Entities; -public interface IUserManager : IGrainWithGuidKey +[Alias("Argon.Api.Grains.Interfaces.IUserGrain")] +public interface IUserGrain : IGrainWithGuidKey { [Alias("CreateUser")] Task CreateUser(UserCredentialsInput input); [Alias("UpdateUser")] - Task UpdateUser(UserCredentialsInput input); + Task UpdateUser(UserEditInput input); [Alias("DeleteUser")] Task DeleteUser(); diff --git a/src/Argon.Api/Grains/ServerGrain.cs b/src/Argon.Api/Grains/ServerGrain.cs index b34743c1..2f5616f4 100644 --- a/src/Argon.Api/Grains/ServerGrain.cs +++ b/src/Argon.Api/Grains/ServerGrain.cs @@ -47,9 +47,9 @@ public async Task CreateServer(ServerInput input, Guid creatorId) public async Task UpdateServer(ServerInput input) { var server = await Get(); - server.Name = input.Name; - server.Description = input.Description; - server.AvatarUrl = input.AvatarUrl; + server.Name = input.Name ?? server.Name; + server.Description = input.Description ?? server.Description; + server.AvatarUrl = input.AvatarUrl ?? server.AvatarUrl; context.Servers.Update(server); await context.SaveChangesAsync(); await _serverEvents.Fire(new ServerModified(PropertyBag.Empty diff --git a/src/Argon.Api/Grains/SessionManager.cs b/src/Argon.Api/Grains/SessionManager.cs index 6dd5dab9..e481eacd 100644 --- a/src/Argon.Api/Grains/SessionManager.cs +++ b/src/Argon.Api/Grains/SessionManager.cs @@ -8,12 +8,12 @@ namespace Argon.Api.Grains; public class SessionManager( IGrainFactory grainFactory, - ILogger logger, + ILogger logger, UserManagerService managerService, IPasswordHashingService passwordHashingService, ApplicationDbContext context) : Grain, ISessionManager { - public async Task GetUser() => await grainFactory.GetGrain(this.GetPrimaryKey()).GetUser(); + public async Task GetUser() => await grainFactory.GetGrain(this.GetPrimaryKey()).GetUser(); public Task Logout() => throw new NotImplementedException(); } \ No newline at end of file diff --git a/src/Argon.Api/Grains/UserManager.cs b/src/Argon.Api/Grains/UserGrain.cs similarity index 78% rename from src/Argon.Api/Grains/UserManager.cs rename to src/Argon.Api/Grains/UserGrain.cs index e84dd564..3f1377a8 100644 --- a/src/Argon.Api/Grains/UserManager.cs +++ b/src/Argon.Api/Grains/UserGrain.cs @@ -7,7 +7,7 @@ namespace Argon.Api.Grains; using Microsoft.EntityFrameworkCore; using Services; -public class UserManager(IPasswordHashingService passwordHashingService, ApplicationDbContext context, IMapper mapper) : Grain, IUserManager +public class UserGrain(IPasswordHashingService passwordHashingService, ApplicationDbContext context, IMapper mapper) : Grain, IUserGrain { public async Task CreateUser(UserCredentialsInput input) { @@ -23,13 +23,12 @@ public async Task CreateUser(UserCredentialsInput input) return mapper.Map(user); } - public async Task UpdateUser(UserCredentialsInput input) + public async Task UpdateUser(UserEditInput input) { var user = await Get(); - user.Email = input.Email; - user.Username = input.Username ?? user.Username; - user.PhoneNumber = input.PhoneNumber ?? user.PhoneNumber; - user.PasswordDigest = passwordHashingService.HashPassword(input.Password) ?? user.PasswordDigest; + user.Username = input.Username ?? user.Username; + user.Username = input.DisplayName ?? user.DisplayName; + user.AvatarFileId = input.AvatarId ?? user.AvatarFileId; context.Users.Update(user); await context.SaveChangesAsync(); return mapper.Map(user); diff --git a/src/Argon.Api/Program.cs b/src/Argon.Api/Program.cs index 22f1655c..7db8efcd 100644 --- a/src/Argon.Api/Program.cs +++ b/src/Argon.Api/Program.cs @@ -9,7 +9,9 @@ using Argon.Api.Features.EmailForms; using Argon.Api.Features.Env; using Argon.Api.Features.Jwt; +using Argon.Api.Features.MediaStorage; using Argon.Api.Features.Otp; +using Argon.Api.Features.Pex; using Argon.Api.Grains.Interfaces; using Argon.Api.Migrations; using Argon.Api.Services; @@ -30,13 +32,18 @@ if (!builder.Environment.IsManaged()) { builder.AddJwt(); + builder.Services.AddAuthorization(); builder.Services.AddControllers().AddNewtonsoftJson(); - builder.Services.AddFusion(RpcServiceMode.Server, true).Rpc.AddWebSocketServer(true).Rpc.AddServer() - .AddServer().AddServer(); + builder.Services.AddFusion(RpcServiceMode.Server, true).Rpc + .AddWebSocketServer(true).Rpc + .AddServer() + .AddServer() + .AddServer(); builder.AddSwaggerWithAuthHeader(); builder.Services.AddAuthorization(); + builder.AddContentDeliveryNetwork(); } - +builder.AddArgonPermissions(); builder.AddSelectiveForwardingUnit(); builder.Services.AddTransient(); builder.Services.AddSingleton(); diff --git a/src/Argon.Api/Services/UserInteraction.cs b/src/Argon.Api/Services/UserInteraction.cs index e1505492..22f4f2a5 100644 --- a/src/Argon.Api/Services/UserInteraction.cs +++ b/src/Argon.Api/Services/UserInteraction.cs @@ -11,7 +11,7 @@ public class UserInteraction(IGrainFactory grainFactory, IFusionContext fusionCo public async Task GetMe() { var userData = await fusionContext.GetUserDataAsync(); - var user = await grainFactory.GetGrain(userData.id).GetUser(); + var user = await grainFactory.GetGrain(userData.id).GetUser(); return mapper.Map(user); } @@ -28,7 +28,7 @@ public async Task CreateServer(CreateServerRequest request) public async Task> GetServers() { var userData = await fusionContext.GetUserDataAsync(); - var servers = await grainFactory.GetGrain(userData.id).GetMyServers(); + var servers = await grainFactory.GetGrain(userData.id).GetMyServers(); return servers.Select(mapper.Map).ToList(); } } diff --git a/src/Argon.Api/appsettings.json b/src/Argon.Api/appsettings.json index a005a192..d51c2804 100644 --- a/src/Argon.Api/appsettings.json +++ b/src/Argon.Api/appsettings.json @@ -47,5 +47,19 @@ "SiteSecret": "", "ChallengeEndpoint": "", "Kind": "NO_CAPTCHA" + }, + "Cdn": { + "Storage": { + "Kind": "Disk", + "BaseUrl": "", + "Login": "", + "Region": "us-east-1", + "Password": "", + "BucketName": "argon-master-storage" + }, + "BaseUrl": "", + "EntryExpire": "01:00:00", + "SignUrl": false, + "SignSecret": "" } } diff --git a/src/Argon.Contracts/IUserAuthorization.cs b/src/Argon.Contracts/IUserAuthorization.cs index 342721c3..e9344b0a 100644 --- a/src/Argon.Contracts/IUserAuthorization.cs +++ b/src/Argon.Contracts/IUserAuthorization.cs @@ -16,6 +16,15 @@ public sealed partial record UserCredentialsInput( [field: Id(4)] string? OtpCode); +[MemoryPackable, Serializable, GenerateSerializer, Alias(nameof(UserEditInput))] +public sealed partial record UserEditInput( + [field: Id(0)] + string? Username, + [field: Id(1)] + string? DisplayName, + [field: Id(2)] + string? AvatarId); + [MemoryPackable, Serializable, GenerateSerializer, Alias(nameof(NewUserCredentialsInput))] public sealed partial record NewUserCredentialsInput( [field: Id(0)] diff --git a/src/Argon.Contracts/etc/Result.cs b/src/Argon.Contracts/etc/Result.cs index e61112be..2e8b2bed 100644 --- a/src/Argon.Contracts/etc/Result.cs +++ b/src/Argon.Contracts/etc/Result.cs @@ -104,7 +104,7 @@ public readonly partial record struct Maybe private Maybe(TResult value) => _value = value; [JsonIgnore, IgnoreMember, MemoryPackIgnore] - public bool HasValue => _value is not null; + public bool HasValue => !EqualityComparer.Default.Equals(_value, default); [JsonIgnore, IgnoreMember, MemoryPackIgnore] public TResult Value => HasValue ? _value! : throw new InvalidOperationException("No value available."); diff --git a/src/Argon.Entry/Program.cs b/src/Argon.Entry/Program.cs index 187a5cd7..743385d3 100644 --- a/src/Argon.Entry/Program.cs +++ b/src/Argon.Entry/Program.cs @@ -1,46 +1,67 @@ +using ActualLab.Fusion; +using ActualLab.Rpc; +using ActualLab.Rpc.Server; using Argon.Api; +using Argon.Api.Controllers; +using Argon.Api.Entities; +using Argon.Api.Extensions; +using Argon.Api.Features.Jwt; +using Argon.Api.Features.Pex; +using Argon.Api.Services; +using Argon.Contracts; +using Orleans.Clustering.Kubernetes; +using Orleans.Configuration; +using Orleans.Serialization; var builder = WebApplication.CreateBuilder(args); -// builder.AddServiceDefaults(); -// builder.AddJwt(); -// builder.Services.AddControllers() -// .AddApplicationPart(typeof(AuthorizationController).Assembly) -// .AddNewtonsoftJson(); -// builder.Services.AddFusion(RpcServiceMode.Server, true) -// .Rpc.AddWebSocketServer(true).Rpc -// .AddServer() -// .AddServer() -// .AddServer(); -// builder.AddSwaggerWithAuthHeader(); -// builder.Services.AddSerializer(x => { -// x.AddMemoryPackSerializer(); -// }).AddOrleansClient(x => -// { -// x.Configure(cluster => -// { -// cluster.ClusterId = "argonchat"; -// cluster.ServiceId = "argonchat"; -// }).AddStreaming(); -// if (builder.Environment.IsProduction()) -// x.UseKubeGatewayListProvider(); -// else -// x.UseLocalhostClustering(); -// }); -// builder.Services.AddAuthorization(); -// builder.Services.AddSingleton(); -// builder.Services.AddAutoMapper(typeof(User).Assembly); +builder.WebHost.ConfigureKestrel(options => { + options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(400); + options.AddServerHeader = false; + options.Limits.Http2.MaxStreamsPerConnection = 100; + options.Limits.Http2.InitialConnectionWindowSize = 65535; + options.Limits.Http2.KeepAlivePingDelay = TimeSpan.FromSeconds(30); + options.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds(10); +}); + +builder.AddArgonPermissions(); +builder.AddServiceDefaults(); +builder.AddJwt(); +builder.Services.AddControllers() + .AddApplicationPart(typeof(AuthorizationController).Assembly) + .AddNewtonsoftJson(); +builder.Services.AddFusion(RpcServiceMode.Server, true) + .Rpc.AddWebSocketServer(true).Rpc + .AddServer() + .AddServer() + .AddServer(); +builder.AddSwaggerWithAuthHeader(); +builder.Services.AddSerializer(x => { + x.AddMemoryPackSerializer(); +}).AddOrleansClient(x => { + x.Configure(cluster => { + cluster.ClusterId = "argonchat"; + cluster.ServiceId = "argonchat"; + }).AddStreaming(); + if (builder.Environment.IsProduction()) + x.UseKubeGatewayListProvider(); + else + x.UseLocalhostClustering(); +}); +builder.Services.AddAuthorization(); +builder.Services.AddSingleton(); +builder.Services.AddAutoMapper(typeof(User).Assembly); var app = builder.Build(); -// app.UseWebSockets(); -// app.MapRpcWebSocketServer(); -// app.UseSwagger(); -// app.UseSwaggerUI(); -// app.UseHttpsRedirection(); -// app.UseAuthentication(); -// app.UseAuthorization(); -// app.MapControllers(); -// app.MapDefaultEndpoints(); +app.UseWebSockets(); +app.MapRpcWebSocketServer(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapDefaultEndpoints(); app.MapGet("/", () => new { diff --git a/src/Argon.Entry/appsettings.json b/src/Argon.Entry/appsettings.json index 45fbf77d..eead1c84 100644 --- a/src/Argon.Entry/appsettings.json +++ b/src/Argon.Entry/appsettings.json @@ -14,4 +14,28 @@ "Key": "5d456e57b6fad40e2d171ffdb4535116596c3b543bf8cfafe6369845cf86a801", "Expires": 228 }, + "Orleans": { + "ClusterId": "argonapi", + "ServiceId": "argonapi" + }, + "Captcha": { + "SiteKey": "", + "SiteSecret": "", + "ChallengeEndpoint": "", + "Kind": "NO_CAPTCHA" + }, + "Cdn": { + "Storage": { + "Kind": "Disk", + "BaseUrl": "", + "Login": "", + "Region": "us-east-1", + "Password": "", + "BucketName": "argon-master-storage" + }, + "BaseUrl": "", + "EntryExpire": "01:00:00", + "SignUrl": false, + "SignSecret": "" + } }