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": ""
+ }
}