Add R2 image storage, upload endpoint, and CDN support
All checks were successful
ci/woodpecker/push/deploy Pipeline was successful

- Backend: R2StorageService, upload controller (POST /api/upload)
- Frontend: CDN url helper, NEXT_PUBLIC_CDN_URL build arg
- Deploy: pass R2 secrets from Woodpecker CI to containers via .env
- Docs: update CLAUDE.md with CDN and upload conventions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 02:34:27 +02:00
parent a30fe60414
commit f0f769c5e8
17 changed files with 407 additions and 4 deletions

View File

@@ -0,0 +1,14 @@
namespace GBSite.Api.Configuration;
public class R2Settings
{
public const string SectionName = "R2";
public required string AccountId { get; set; }
public required string AccessKeyId { get; set; }
public required string SecretAccessKey { get; set; }
public required string BucketName { get; set; }
public string PublicUrl { get; set; } = "https://cdn.goodbrick.com.ua";
public string ServiceUrl => $"https://{AccountId}.r2.cloudflarestorage.com";
}

View File

@@ -0,0 +1,44 @@
using GBSite.Api.Services;
using Microsoft.AspNetCore.Mvc;
namespace GBSite.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class UploadController : ControllerBase
{
private readonly R2StorageService? _storage;
public UploadController(R2StorageService? storage = null)
{
_storage = storage;
}
[HttpPost]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> Upload(
IFormFile file,
[FromForm] string? path,
CancellationToken ct)
{
if (_storage is null)
return StatusCode(503, new { error = "Storage service not configured" });
if (file.Length == 0)
return BadRequest(new { error = "File is empty" });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp", "image/avif" };
if (!allowedTypes.Contains(file.ContentType))
return BadRequest(new { error = $"File type '{file.ContentType}' not allowed" });
var fileName = Path.GetFileName(file.FileName);
var key = string.IsNullOrEmpty(path)
? fileName
: $"{path.Trim('/')}/{fileName}";
await using var stream = file.OpenReadStream();
var cdnUrl = await _storage.UploadAsync(stream, key, file.ContentType, ct);
return Ok(new { url = cdnUrl, key });
}
}

View File

@@ -4,9 +4,11 @@
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>805cad54-8a19-4713-b893-a1ec63696146</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.*" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
<PackageReference Include="Npgsql" Version="10.0.1" />
</ItemGroup>

View File

@@ -1,9 +1,32 @@
using Amazon.S3;
using GBSite.Api.Configuration;
using GBSite.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// R2 Storage
var r2Section = builder.Configuration.GetSection(R2Settings.SectionName);
builder.Services.Configure<R2Settings>(r2Section);
var r2Settings = r2Section.Get<R2Settings>();
if (r2Settings is not null && !string.IsNullOrEmpty(r2Settings.AccountId))
{
builder.Services.AddSingleton<IAmazonS3>(_ =>
{
var config = new AmazonS3Config
{
ServiceURL = r2Settings.ServiceUrl,
ForcePathStyle = true
};
return new AmazonS3Client(r2Settings.AccessKeyId, r2Settings.SecretAccessKey, config);
});
builder.Services.AddSingleton<R2StorageService>();
}
var app = builder.Build();
if (app.Environment.IsDevelopment())

View File

@@ -0,0 +1,43 @@
using Amazon.S3;
using Amazon.S3.Model;
using GBSite.Api.Configuration;
using Microsoft.Extensions.Options;
namespace GBSite.Api.Services;
public class R2StorageService
{
private readonly IAmazonS3 _s3;
private readonly R2Settings _settings;
public R2StorageService(IAmazonS3 s3, IOptions<R2Settings> settings)
{
_s3 = s3;
_settings = settings.Value;
}
public async Task<string> UploadAsync(
Stream stream,
string key,
string contentType,
CancellationToken ct = default)
{
var request = new PutObjectRequest
{
BucketName = _settings.BucketName,
Key = key,
InputStream = stream,
ContentType = contentType,
DisablePayloadSigning = true
};
await _s3.PutObjectAsync(request, ct);
return $"{_settings.PublicUrl.TrimEnd('/')}/{key}";
}
public async Task DeleteAsync(string key, CancellationToken ct = default)
{
await _s3.DeleteObjectAsync(_settings.BucketName, key, ct);
}
}

View File

@@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"R2": {
"AccountId": "",
"AccessKeyId": "",
"SecretAccessKey": "",
"BucketName": "",
"PublicUrl": "https://cdn.goodbrick.com.ua"
}
}