Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/COMP.API/COMP.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>COMP.API</AssemblyName>
<RootNamespace>Comp</RootNamespace>
<RootNamespace>COMP.API</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FastEndpoints" Version="5.31.0" />
<PackageReference Include="FastEndpoints.Swagger" Version="5.31.0" />
<PackageReference Include="AWSSDK.S3" Version="4.0.18.1" />
<PackageReference Include="Linqkit.Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Mime" Version="3.8.0" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.19" />
</ItemGroup>

Expand Down
9 changes: 9 additions & 0 deletions src/COMP.API/Configuration/GatewayOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace COMP.API.Configuration;

public class GatewayOptions
{
public const string SectionName = "Gateway";

public string? Ipfs { get; set; }
public string? Arweave { get; set; }
}
15 changes: 15 additions & 0 deletions src/COMP.API/Configuration/S3Options.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace COMP.API.Configuration;

public class S3Options
{
public const string SectionName = "S3";

public bool Enabled { get; set; }
public string BucketName { get; set; } = string.Empty;
public string Region { get; set; } = "us-east-1";
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public string? ServiceUrl { get; set; }
public string? PublicBaseUrl { get; set; }
public int MaxParallelUploads { get; set; } = 4;
}
45 changes: 45 additions & 0 deletions src/COMP.API/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Amazon.S3;
using COMP.API.Configuration;
using COMP.API.Services;

namespace COMP.API.Extensions;

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddS3ImageUpload(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<S3Options>(configuration.GetSection(S3Options.SectionName));
services.Configure<GatewayOptions>(configuration.GetSection(GatewayOptions.SectionName));

S3Options s3Options = configuration.GetSection(S3Options.SectionName).Get<S3Options>() ?? new();

if (!s3Options.Enabled)
return services;

services.AddSingleton<IAmazonS3>(_ =>
{
AmazonS3Config config = new()
{
RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(s3Options.Region)
};

if (!string.IsNullOrWhiteSpace(s3Options.ServiceUrl))
{
config.ServiceURL = s3Options.ServiceUrl;
config.ForcePathStyle = true;
}

return new AmazonS3Client(s3Options.AccessKey, s3Options.SecretKey, config);
});

services.AddSingleton<AwsS3Service>();
services.AddSingleton<ImageUploadService>();
services.AddHttpClient("ImageFetch", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("User-Agent", "COMP-API/1.0");
});

return services;
}
}
15 changes: 12 additions & 3 deletions src/COMP.API/Modules/Handlers/MetadataHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
using Microsoft.EntityFrameworkCore;
using LinqKit;
using COMP.Data.Data;
using COMP.API.Services;

namespace COMP.API.Modules.Handlers;

public class MetadataHandler
(
IDbContextFactory<MetadataDbContext> _dbContextFactory
IDbContextFactory<MetadataDbContext> _dbContextFactory,
ImageUploadService? _imageUploadService = null
)
{
// Fetch data by subject (checks both registry and on-chain tables)
Expand All @@ -29,14 +31,21 @@ public async Task<IResult> GetTokenMetadataAsync(string subject)
return Results.NotFound();

// Prioritize on-chain data, fall back to registry
string? logo = onChainToken?.Logo ?? registryToken?.Logo;
string? cachedLogo = _imageUploadService is not null
? await _imageUploadService.GetCachedUrlAsync(logo)
: null;

if (cachedLogo is null)
_imageUploadService?.TryEnqueueUpload(subject, logo);

return Results.Ok(new
{
subject,
policyId = onChainToken?.PolicyId ?? registryToken?.PolicyId ?? "",
name = onChainToken?.Name ?? registryToken?.Name,
ticker = registryToken?.Ticker,
logo = onChainToken?.Logo ?? registryToken?.Logo,
logo = cachedLogo ?? logo,
description = onChainToken?.Description ?? registryToken?.Description,
decimals = onChainToken?.Decimals ?? registryToken?.Decimals ?? 0,
quantity = onChainToken?.Quantity,
Expand Down Expand Up @@ -79,7 +88,7 @@ public async Task<IResult> BatchTokenMetadataAsync(
{
string lowerPolicyId = policyId.ToLowerInvariant();
registryPredicate = registryPredicate.And(token =>
token.Subject.Substring(0, 56).Equals(lowerPolicyId, StringComparison.CurrentCultureIgnoreCase));
token.Subject.Substring(0, 56).ToLower() == lowerPolicyId);
}
if (requireName)
registryPredicate = registryPredicate.And(token => !string.IsNullOrEmpty(token.Name));
Expand Down
6 changes: 4 additions & 2 deletions src/COMP.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
using Scalar.AspNetCore;
using Microsoft.EntityFrameworkCore;
using COMP.Data.Data;
using COMP.API.Extensions;
using COMP.API.Modules.Handlers;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContextFactory<MetadataDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddSingleton<MetadataHandler>();
builder.Services.AddScoped<MetadataHandler>();
builder.Services.AddS3ImageUpload(builder.Configuration);

builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument(o =>
Expand Down Expand Up @@ -45,4 +47,4 @@
.WithOpenApiRoutePattern("/swagger/{documentName}/swagger.json")
);

app.Run();
app.Run();
61 changes: 61 additions & 0 deletions src/COMP.API/Services/AwsS3Service.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Amazon.S3;
using Amazon.S3.Model;
using COMP.API.Configuration;
using Microsoft.Extensions.Options;

namespace COMP.API.Services;

public class AwsS3Service(
IAmazonS3 s3Client,
IOptions<S3Options> s3Options,
ILogger<AwsS3Service> logger)
{
private readonly S3Options _options = s3Options.Value;

public async Task<string> UploadAsync(string key, byte[] content, string? contentType = null, CancellationToken ct = default)
{
using MemoryStream stream = new(content);
PutObjectRequest request = new()
{
BucketName = _options.BucketName,
Key = key,
InputStream = stream,
ContentType = contentType ?? "application/octet-stream",
CannedACL = S3CannedACL.PublicRead
};

await s3Client.PutObjectAsync(request, ct);

string publicUrl = GetPublicUrl(key);
logger.LogInformation("Uploaded to S3: {Key} ({Size} bytes)", key, content.Length);

return publicUrl;
}

public async Task<bool> ExistsAsync(string key, CancellationToken ct = default)
{
try
{
GetObjectMetadataRequest request = new()
{
BucketName = _options.BucketName,
Key = key
};

await s3Client.GetObjectMetadataAsync(request, ct);
return true;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return false;
}
}

public string GetPublicUrl(string key)
{
if (!string.IsNullOrEmpty(_options.PublicBaseUrl))
return $"{_options.PublicBaseUrl.TrimEnd('/')}/{key}";

return $"https://{_options.BucketName}.s3.{_options.Region}.amazonaws.com/{key}";
}
}
Loading