diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index c37a619..33d63a2 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -6,7 +6,7 @@ Weather Test - + Storage diff --git a/Components/Pages/Storage.razor b/Components/Pages/Storage.razor new file mode 100644 index 0000000..e2e64a5 --- /dev/null +++ b/Components/Pages/Storage.razor @@ -0,0 +1,116 @@ + +@page "/storage" +@inject HttpClient Http +@using System.Net.Http.Json +@using Pldpro.Web.Models + +Storage + +S3 Storage + + + + Buckets + + + Erstellen + + + + @if (buckets is null) + { + (lädt...) + } + else if (!buckets.Any()) + { + (keine Buckets) + } + else + { + @foreach (var b in buckets) + { + + + @b.Name + + } + } + + + +@if (!string.IsNullOrEmpty(selectedBucket)) +{ + + + Objekte in '@selectedBucket' + + + + + + + Key + Größe + Geändert + + + @context.Key + @context.Size + @context.LastModified + + + +} + +@code { + private record BucketVm(string Name, DateTime? CreationDate); + private record ObjectVm(string Key, long? Size, DateTime? LastModified); + + private List? buckets; + private List? objects; + private string? selectedBucket; + private string newBucketName = ""; + private const long StreamLimit = 512L * 1024 * 1024; // 512 MB (Program.cs erhöht Multipart-Limit) + + protected override async Task OnInitializedAsync() + => await LoadBuckets(); + + private async Task LoadBuckets() + { + var data = await Http.GetFromJsonAsync>("/api/storage/buckets"); + buckets = data ?? new(); + StateHasChanged(); + } + + private async Task SelectBucket(string name) + { + selectedBucket = name; + objects = await Http.GetFromJsonAsync>($"/api/storage/buckets/{name}/objects") ?? new(); + } + + private async Task CreateBucket() + { + if (string.IsNullOrWhiteSpace(newBucketName)) return; + await Http.PostAsJsonAsync("/api/storage/buckets", new S3CreateBucketDto { BucketName = newBucketName! }); + newBucketName = ""; + await LoadBuckets(); + } + + private async Task OnFilesSelected(InputFileChangeEventArgs e) + { + if (string.IsNullOrEmpty(selectedBucket)) return; + + foreach (var file in e.GetMultipleFiles()) + { + using var stream = file.OpenReadStream(StreamLimit); + using var content = new MultipartFormDataContent(); + content.Add(new StreamContent(stream), "file", file.Name); + + var resp = await Http.PostAsync($"/api/storage/buckets/{selectedBucket}/upload", content); + resp.EnsureSuccessStatusCode(); + } + // Refresh list + objects = await Http.GetFromJsonAsync>($"/api/storage/buckets/{selectedBucket}/objects") ?? new(); + } +} diff --git a/Components/_Imports.razor b/Components/_Imports.razor index 8fdb8b6..38ad5b7 100644 --- a/Components/_Imports.razor +++ b/Components/_Imports.razor @@ -10,3 +10,4 @@ @using MudBlazor.Services @using Pldpro.Web @using Pldpro.Web.Components +@using Amazon.S3 \ No newline at end of file diff --git a/Models/S3Settings.cs b/Models/S3Settings.cs new file mode 100644 index 0000000..0362d49 --- /dev/null +++ b/Models/S3Settings.cs @@ -0,0 +1,12 @@ + +namespace Pldpro.Web.Models; + +public sealed class S3Settings +{ + public string ServiceURL { get; set; } = string.Empty; + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public bool UseHttp { get; set; } = true; + public bool ForcePathStyle { get; set; } = true; + public string? DefaultBucketPrefix { get; set; } +} diff --git a/Pldpro.Web.csproj b/Pldpro.Web.csproj index 20b9f68..039cf14 100644 --- a/Pldpro.Web.csproj +++ b/Pldpro.Web.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,6 +8,8 @@ + + diff --git a/Program.cs b/Program.cs index 092c59c..963b664 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,17 @@ +using Amazon.Runtime; +using Amazon.S3; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Options; using MudBlazor.Services; using Pldpro.Web.Components; +using Pldpro.Web.Components.Pages; +using Pldpro.Web.Models; +using Pldpro.Web.Services; +using System.Net.NetworkInformation; +using System.Runtime.Intrinsics.Arm; +using static MudBlazor.CategoryTypes; +using static MudBlazor.Colors; + var builder = WebApplication.CreateBuilder(args); @@ -10,6 +22,32 @@ builder.Services.AddMudServices(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); + +// --- S3 / RustFS Settings binding --- +builder.Services.Configure(builder.Configuration.GetSection("S3")); + +// Optional: größere Uploads erlauben (z. B. 512MB) +builder.Services.Configure(o => { o.MultipartBodyLengthLimit = 512L * 1024 * 1024; }); + +// IAmazonS3 via DI (lokaler S3-kompatibler Endpoint) +builder.Services.AddSingleton(sp => +{ + var s = sp.GetRequiredService>().Value; + var cfg = new Amazon.S3.AmazonS3Config + { + ServiceURL = s.ServiceURL, + ForcePathStyle = s.ForcePathStyle, + UseHttp = s.UseHttp + // AuthenticationRegion ist bei Custom-S3 i. d. R. egal + } + ; + var creds = new BasicAWSCredentials(s.AccessKey, s.SecretKey); + return new AmazonS3Client(creds, cfg); + }); + +// Domain-Service +builder.Services.AddScoped(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -29,4 +67,45 @@ app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); + +// --- Minimal APIs für Storage --- +var storage = app.MapGroup("/api/storage"); + +// Buckets auflisten +storage.MapGet("/buckets", async (IStorageService svc) => +{ + var result = await svc.ListBucketsAsync(); + return Results.Ok(result); + }); + +// Bucket erstellen +storage.MapPost("/buckets", async (IStorageService svc, S3CreateBucketDto dto) => +{ + if (string.IsNullOrWhiteSpace(dto.BucketName)) return Results.BadRequest("BucketName required"); + await svc.CreateBucketAsync(dto.BucketName); + return Results.Ok(); + }).DisableAntiforgery(); // für XHR-POST ohne Token + +// Objekte eines Buckets auflisten +storage.MapGet("/buckets/{bucket}/objects", async (IStorageService svc, string bucket) => +{ + var objects = await svc.ListObjectsAsync(bucket); + return Results.Ok(objects); + }); + +// Datei in Bucket hochladen (Form-Data: file) +storage.MapPost("/buckets/{bucket}/upload", async (HttpRequest req, IStorageService svc, string bucket) => +{ + if (!req.HasFormContentType) return Results.BadRequest("Multipart/form-data expected"); + var form = await req.ReadFormAsync(); + var file = form.Files["file"]; + if (file is null) return Results.BadRequest("'file' missing"); + + await using var stream = file.OpenReadStream(); // Streamlimit über FormOptions konfiguriert + await svc.UploadObjectAsync(bucket, file.FileName, stream, file.ContentType ?? "application/octet-stream"); + return Results.Ok(); + }).DisableAntiforgery(); // für Blazor XHR Upload + + + app.Run(); diff --git a/Program.txt b/Program.txt new file mode 100644 index 0000000..4175989 Binary files /dev/null and b/Program.txt differ diff --git a/Services/IStorageService.cs b/Services/IStorageService.cs new file mode 100644 index 0000000..8640212 --- /dev/null +++ b/Services/IStorageService.cs @@ -0,0 +1,12 @@ + +using Pldpro.Web.Services.Models; + +namespace Pldpro.Web.Services; + +public interface IStorageService +{ + Task> ListBucketsAsync(CancellationToken ct = default); + Task CreateBucketAsync(string bucketName, CancellationToken ct = default); + Task> ListObjectsAsync(string bucket, CancellationToken ct = default); + Task UploadObjectAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default); +} diff --git a/Services/Models/BucketItem.cs b/Services/Models/BucketItem.cs new file mode 100644 index 0000000..3521aaf --- /dev/null +++ b/Services/Models/BucketItem.cs @@ -0,0 +1,4 @@ + +namespace Pldpro.Web.Services.Models; + +public sealed record BucketItem(string Name, DateTime? CreationDate); diff --git a/Services/Models/ObjectItem.cs b/Services/Models/ObjectItem.cs new file mode 100644 index 0000000..50b3981 --- /dev/null +++ b/Services/Models/ObjectItem.cs @@ -0,0 +1,4 @@ + +namespace Pldpro.Web.Services.Models; + +public sealed record ObjectItem(string Key, long? Size, DateTime? LastModified); diff --git a/Services/Models/S3CreateBucketDto.cs b/Services/Models/S3CreateBucketDto.cs new file mode 100644 index 0000000..d83e30c --- /dev/null +++ b/Services/Models/S3CreateBucketDto.cs @@ -0,0 +1,7 @@ + +namespace Pldpro.Web.Models; + +public sealed class S3CreateBucketDto +{ + public string BucketName { get; set; } = string.Empty; +} diff --git a/Services/S3StorageService.cs b/Services/S3StorageService.cs new file mode 100644 index 0000000..dc27365 --- /dev/null +++ b/Services/S3StorageService.cs @@ -0,0 +1,55 @@ + +using Amazon.S3; +using Amazon.S3.Model; +using Pldpro.Web.Services.Models; + +namespace Pldpro.Web.Services; + +public sealed class S3StorageService(IAmazonS3 s3) : IStorageService +{ + private readonly IAmazonS3 _s3 = s3; + + public async Task> ListBucketsAsync(CancellationToken ct = default) + { + var resp = await _s3.ListBucketsAsync(ct); + return resp.Buckets.Select(b => new BucketItem(b.BucketName, b.CreationDate)); + } + + public async Task CreateBucketAsync(string bucketName, CancellationToken ct = default) + { + // Für S3-kompatible Endpoints reicht häufig nur der Name + var req = new PutBucketRequest { BucketName = bucketName }; + await _s3.PutBucketAsync(req, ct); + } + + public async Task> ListObjectsAsync(string bucket, CancellationToken ct = default) + { + var items = new List(); + string? token = null; + do + { + var resp = await _s3.ListObjectsV2Async(new ListObjectsV2Request + { + BucketName = bucket, + ContinuationToken = token + }, ct); + + items.AddRange(resp.S3Objects.Select(o => new ObjectItem(o.Key, o.Size, o.LastModified))); + token = resp.IsTruncated ? resp.NextContinuationToken : null; + } while (token is not null); + + return items; + } + + public async Task UploadObjectAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default) + { + var req = new PutObjectRequest + { + BucketName = bucket, + Key = key, + InputStream = content, + ContentType = contentType + }; + await _s3.PutObjectAsync(req, ct); + } +} diff --git a/appsettings.json b/appsettings.json index 10f68b8..9c1866e 100644 --- a/appsettings.json +++ b/appsettings.json @@ -5,5 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "S3": { + "ServiceURL": "http://192.168.1.102:9000", + "AccessKey": "your-access-key", + "SecretKey": "your-secret-key", + "UseHttp": true, + "ForcePathStyle": true, + "DefaultBucketPrefix": "pld-" // optional + } + }