Add R2 image storage, upload endpoint, and CDN support
All checks were successful
ci/woodpecker/push/deploy Pipeline was successful
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:
14
backend/src/GBSite.Api/Configuration/R2Settings.cs
Normal file
14
backend/src/GBSite.Api/Configuration/R2Settings.cs
Normal 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";
|
||||
}
|
||||
44
backend/src/GBSite.Api/Controllers/UploadController.cs
Normal file
44
backend/src/GBSite.Api/Controllers/UploadController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
43
backend/src/GBSite.Api/Services/R2StorageService.cs
Normal file
43
backend/src/GBSite.Api/Services/R2StorageService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"R2": {
|
||||
"AccountId": "",
|
||||
"AccessKeyId": "",
|
||||
"SecretAccessKey": "",
|
||||
"BucketName": "",
|
||||
"PublicUrl": "https://cdn.goodbrick.com.ua"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user