Skip to content

Commit

Permalink
Refactor file handling methods and add CDN configuration
Browse files Browse the repository at this point in the history
- Removed unused code related to specific storage types
- Refactored file handling methods in the FilesController class for better readability and maintainability
- Added a new CDN configuration option for payload signature mode to enhance security
  • Loading branch information
0xF6 committed Jan 3, 2025
1 parent 87119ed commit 77e9b5e
Show file tree
Hide file tree
Showing 36 changed files with 326 additions and 265 deletions.
51 changes: 24 additions & 27 deletions src/Argon.Api/Controllers/FilesController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
namespace Argon.Controllers;

using Features.MediaStorage;
using Features.MediaStorage.Storages;
using Features.Pex;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -14,26 +13,26 @@ public class FilesController(
IGrainFactory grainFactory,
IContentTypeProvider contentType) : ControllerBase
{
// work only when cdn\storage set local disk or in memory
[HttpGet("/files/{nsPath}/{nsId:guid}/{kind}/{shard}/{fileId}")]
public async ValueTask<IActionResult> 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();
//// work only when cdn\storage set local disk or in memory
//[HttpGet("/files/{fileId}")]
//public async ValueTask<IActionResult> 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);
// var ns = new StorageNameSpace(nsPath, nsId);
// var assetId = AssetId.FromFileId(fileId);
// var mem = DiskContentStorage.OpenFileRead(ns, assetId);

if (contentType.TryGetContentType(fileId, out var mime))
return File(mem, mime);
return File(mem, "application/octet-stream");
}
// if (contentType.TryGetContentType(fileId, out var mime))
// return File(mem, mime);
// return File(mem, "application/octet-stream");
//}

[HttpPost("/files/server/{serverId:guid}/avatar"), Authorize(JwtBearerDefaults.AuthenticationScheme)]
public async ValueTask<IActionResult> UploadServerAvatar([FromRoute] Guid serverId, IFormFile file)
Expand All @@ -43,33 +42,31 @@ public async ValueTask<IActionResult> UploadServerAvatar([FromRoute] Guid server
if (!await permissions.CanAccess(ArgonEntitlement.ManageServer, userId, serverId))
return StatusCode(401);

var assetId = AssetId.Avatar();
var ns = StorageNameSpace.ForServer(serverId);
var result = await cdn.CreateAssetAsync(ns, assetId, file);
var assetId = AssetId.ServerFile(serverId, "png");
var result = await cdn.CreateAssetAsync(assetId, file);

if (result.HasValue)
return Ok(result);

await grainFactory.GetGrain<IServerGrain>(serverId)
.UpdateServer(new ServerInput(null, null, assetId.ToFileId()));

return Ok(cdn.GenerateAssetUrl(ns, assetId));
return Ok(cdn.GenerateAssetUrl(assetId));
}

[HttpPost("/files/user/@me/avatar"), Authorize(JwtBearerDefaults.AuthenticationScheme)]
public async ValueTask<IActionResult> UploadUserAvatar(IFormFile file)
{
var userId = HttpContext.GetUserId();
var assetId = AssetId.Avatar();
var ns = StorageNameSpace.ForUser(userId);
var result = await cdn.CreateAssetAsync(ns, assetId, file);
var assetId = AssetId.Avatar(userId);
var result = await cdn.CreateAssetAsync(assetId, file);

if (result.HasValue)
return Ok(result);

await grainFactory.GetGrain<IUserGrain>(userId)
.UpdateUser(new UserEditInput(null, null, assetId.ToFileId()));

return Ok(cdn.GenerateAssetUrl(ns, assetId));
return Ok(cdn.GenerateAssetUrl(assetId));
}
}
6 changes: 0 additions & 6 deletions src/Argon.Api/Entities/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,4 @@ private static LambdaExpression GetSoftDeleteFilter(Type type)
var notDeleted = Expression.Not(isDeletedProperty);
return Expression.Lambda(notDeleted, parameter);
}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new TimeStampAndSoftDeleteInterceptor());
base.OnConfiguring(optionsBuilder);
}
}
14 changes: 14 additions & 0 deletions src/Argon.Api/Features/Auth/AuthorizationFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Argon.Features.Auth;

using Services;

public static class AuthorizationFeature
{
public static void AddArgonAuthorization(this WebApplicationBuilder builder)
{
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IPasswordHashingService, PasswordHashingService>();
builder.Services.AddTransient<UserManagerService>();
builder.Services.AddDataProtection();
}
}
9 changes: 9 additions & 0 deletions src/Argon.Api/Features/EF/DatabaseFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Argon.Features.EF;

public static class DatabaseFeature
{
public static void AddPooledDatabase<T>(this WebApplicationBuilder builder) where T : DbContext
=> builder.Services.AddPooledDbContextFactory<T>(x => x
.EnableDetailedErrors().EnableSensitiveDataLogging().UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
.AddInterceptors(new TimeStampAndSoftDeleteInterceptor()));
}
76 changes: 19 additions & 57 deletions src/Argon.Api/Features/MediaStorage/AssetId.cs
Original file line number Diff line number Diff line change
@@ -1,71 +1,33 @@
namespace Argon.Features.MediaStorage;

public readonly struct AssetId(Guid assetId, AssetScope scope, AssetKind kind, string extensions)

public abstract class AssetId(Guid assetId, string extensions)
{
public string ToFileId()
=> $"{assetId:D}-{((byte)scope):X2}-{((byte)kind):X2}-00.{extensions}"; // last two zero reserved
=> $"{assetId:N}.{extensions}";

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 abstract string GetFilePath();

public Dictionary<string, string> GetTags(StorageNameSpace @namespace)
{
var tags = new Dictionary<string, string>
{
{
nameof(AssetScope), $"{scope}"
},
{
nameof(AssetKind), $"{kind}"
},
{
$"Id", $"{assetId}"
},
{
$"Namespace", $"{@namespace.path}:{@namespace.id}"
}
};
return tags;
}

public static AssetId FromFileId(string fileId)
{
if (fileId.Length < 46)
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));
var ext = fileId.Split('.').Last();
return new AssetId(guid, (AssetScope)scope, (AssetKind)kind, ext);
}
public static AssetId Avatar(Guid userId)
=> new UserAssetId(userId, Guid.NewGuid(), "png");

public static AssetId Avatar() => new(Guid.NewGuid(), AssetScope.ProfileAsset, AssetKind.Image, "png");
public static AssetId VideoAvatar() => new(Guid.NewGuid(), AssetScope.ProfileAsset, AssetKind.VideoNoSound, "mp4");
public static AssetId ServerFile(Guid serverId, string extension)
=> new UserAssetId(serverId, Guid.NewGuid(), extension);
}

public enum AssetScope : byte
public sealed class UserAssetId(Guid userId, Guid assetId, string extensions) : AssetId(assetId, extensions)
{
ProfileAsset,
ChatAsset,
ServiceAsset
public override string GetFilePath()
=> $"user/{userId:N}/{ToFileId()}";
}

public enum AssetKind : byte
public sealed class ServerAssetId(Guid serverId, Guid assetId, string extensions) : AssetId(assetId, extensions)
{
public override string GetFilePath()
=> $"server/{serverId:N}/{ToFileId()}";
}
public sealed class ChannelAssetId(Guid serverId, Guid channelId, Guid assetId, string extensions) : AssetId(assetId, extensions)
{
Image,
Video, // only png
VideoNoSound, // gif
File,
ServerContent,
ServiceContent,
Sound
public override string GetFilePath()
=> $"server/{serverId:N}/channel/{channelId:N}/{ToFileId()}";
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static IServiceCollection AddS3Storage(this WebApplicationBuilder builder
config.RegionCode = options.Region;
config.Credentials = new StringAccessKey(options.Login, options.Password);
config.NamingMode = NamingMode.PathStyle;
config.PayloadSignatureMode = SignatureMode.FullSignature;
});

var storageServices = storageContainer.BuildServiceProvider();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
namespace Argon.Features.MediaStorage;

public class DiskContentDeliveryNetwork([FromKeyedServices(IContentStorage.DiskContentStorageKey)] IContentStorage storage,
public class DiskContentDeliveryNetwork(
[FromKeyedServices(IContentStorage.DiskContentStorageKey)] IContentStorage storage,
ILogger<YandexContentDeliveryNetwork> logger) : IContentDeliveryNetwork
{
public IContentStorage Storage { get; } = storage;
public async ValueTask<Maybe<UploadError>> CreateAssetAsync(StorageNameSpace ns, AssetId asset, Stream file)
public IContentStorage Storage { get; } = storage;

public async ValueTask<Maybe<UploadError>> CreateAssetAsync(AssetId asset, Stream file)
{
try
{
await Storage.UploadFile(ns, asset, file);
await Storage.UploadFile(asset, file);
return Maybe<UploadError>.None();
}
catch (Exception e)
Expand All @@ -18,9 +20,9 @@ public async ValueTask<Maybe<UploadError>> CreateAssetAsync(StorageNameSpace ns,
}
}

public ValueTask<Maybe<UploadError>> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, Stream file)
public ValueTask<Maybe<UploadError>> ReplaceAssetAsync(AssetId asset, Stream file)
=> throw new NotImplementedException();

public string GenerateAssetUrl(StorageNameSpace ns, AssetId asset)
=> new($"/files/{ns.ToPath()}/{asset.GetFilePath()}?nocache=1");
public string GenerateAssetUrl(AssetId asset)
=> new($"/files/{asset.GetFilePath()}?nocache=1");
}
14 changes: 7 additions & 7 deletions src/Argon.Api/Features/MediaStorage/IContentDeliveryNetwork.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ public interface IContentDeliveryNetwork
{
IContentStorage Storage { get; }

ValueTask<Maybe<UploadError>> CreateAssetAsync(StorageNameSpace ns, AssetId asset, IFormFile file)
ValueTask<Maybe<UploadError>> CreateAssetAsync(AssetId asset, IFormFile file)
{
var memory = file.OpenReadStream();
return CreateAssetAsync(ns, asset, memory);
return CreateAssetAsync(asset, memory);
}

ValueTask<Maybe<UploadError>> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, IFormFile file)
ValueTask<Maybe<UploadError>> ReplaceAssetAsync(AssetId asset, IFormFile file)
{
using var memory = file.OpenReadStream();
return ReplaceAssetAsync(ns, asset, memory);
return ReplaceAssetAsync(asset, memory);
}

ValueTask<Maybe<UploadError>> CreateAssetAsync(StorageNameSpace ns, AssetId asset, Stream file);
ValueTask<Maybe<UploadError>> ReplaceAssetAsync(StorageNameSpace ns, AssetId asset, Stream file);
string GenerateAssetUrl(StorageNameSpace ns, AssetId asset);
ValueTask<Maybe<UploadError>> CreateAssetAsync(AssetId asset, Stream file);
ValueTask<Maybe<UploadError>> ReplaceAssetAsync(AssetId asset, Stream file);
string GenerateAssetUrl(AssetId asset);
}
14 changes: 3 additions & 11 deletions src/Argon.Api/Features/MediaStorage/IContentStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ public interface IContentStorage
{
ValueTask<StorageSpace> GetStorageStats();

ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Stream data);
ValueTask UploadFile(AssetId assetId, Stream data);

ValueTask DeleteFile(StorageNameSpace block, AssetId assetId);
ValueTask DeleteFile(AssetId assetId);


public const string GenericS3StorageKey = "cdn:bucket:s3";
Expand All @@ -15,15 +15,6 @@ public interface IContentStorage
}


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,
Expand All @@ -39,4 +30,5 @@ public class StorageOptions
public string Region { get; set; }
public string Password { get; set; }
public string BucketName { get; set; }
public bool EnableTags { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ public class DiskContentStorage : IContentStorage
public ValueTask<StorageSpace> GetStorageStats()
=> new(new StorageSpace(0, 0, 0));

public async ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Stream data)
public async ValueTask UploadFile(AssetId assetId, Stream data)
{
var fullPath = $"./storage/{block.ToPath()}/{assetId.GetFilePath()}";
var fullPath = $"./storage/{assetId.GetFilePath()}";
var directory = new FileInfo(fullPath).Directory!;

if (!directory.Exists)
Expand All @@ -18,12 +18,12 @@ public async ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Strea
await data.CopyToAsync(stream);
}

public async ValueTask DeleteFile(StorageNameSpace block, AssetId assetId)
public async ValueTask DeleteFile(AssetId assetId)
{
if (File.Exists($"./storage/{block.ToPath()}/{assetId.GetFilePath()}"))
File.Delete($"./storage/{block.ToPath()}/{assetId.GetFilePath()}");
if (File.Exists($"./storage/{assetId.GetFilePath()}"))
File.Delete($"./storage/{assetId.GetFilePath()}");
}

public static Stream OpenFileRead(StorageNameSpace block, AssetId assetId)
=> File.OpenRead($"./storage/{block.ToPath()}/{assetId.GetFilePath()}");
public static Stream OpenFileRead(AssetId assetId)
=> File.OpenRead($"./storage/{assetId.GetFilePath()}");
}
15 changes: 6 additions & 9 deletions src/Argon.Api/Features/MediaStorage/Storages/S3ContentStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,25 @@ public class S3ContentStorage([FromKeyedServices("GenericS3:client")] IObjectCli
public ValueTask<StorageSpace> GetStorageStats()
=> new(new StorageSpace(0, 0, 0));

public async ValueTask UploadFile(StorageNameSpace block, AssetId assetId, Stream data)
public async ValueTask UploadFile(AssetId assetId, Stream data)
{
var config = options.Value;
logger.LogInformation("Begin upload file to s3 storage, '{bucketName}' to '{path}'",
config.BucketName, $"{block.ToPath()}/{assetId.GetFilePath()}");
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);
});
config.BucketName, $"{assetId.GetFilePath()}");
var result = await s3Client.PutObjectAsync(config.BucketName, $"{assetId.GetFilePath()}", data);

if (!result.IsSuccess)
{
logger.LogCritical("Failed upload file to s3 storage, '{bucketName}' to '{path}', errorCode: {errorCode}, errorMessage: {errorMessage}",
config.BucketName, $"{block.ToPath()}/{assetId.GetFilePath()}", result.Error?.Code, result.Error?.Message);
config.BucketName, $"{assetId.GetFilePath()}", result.Error?.Code, result.Error?.Message);
throw new InvalidOperationException();
}
}

public async ValueTask DeleteFile(StorageNameSpace block, AssetId assetId)
public async ValueTask DeleteFile(AssetId assetId)
{
var config = options.Value;
var result = await s3Client.DeleteObjectAsync(config.BucketName, $"/{block.ToPath()}/{assetId.GetFilePath()}");
var result = await s3Client.DeleteObjectAsync(config.BucketName, $"{assetId.GetFilePath()}");

if (!result.IsSuccess)
throw new InvalidOperationException();
Expand Down
Loading

0 comments on commit 77e9b5e

Please sign in to comment.