From d333409c197ef86b07c14a1547882692debbe8f0 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 9 Feb 2026 18:38:12 +0100 Subject: [PATCH] Update --- Components/Pages/Storage.razor | 29 +++++- Pldpro.Web.csproj | 1 + Program.cs | 76 ++++++++++++-- Program.txt | Bin 14108 -> 0 bytes Services/IStorageService.cs | 1 + Services/S3StorageService.cs | 28 +++++- .../Storage/IStorageMetadataRepository.cs | 20 ++++ Services/Storage/StorageMetaDataRepository.cs | 93 ++++++++++++++++++ appsettings.json | 4 + 9 files changed, 242 insertions(+), 10 deletions(-) delete mode 100644 Program.txt create mode 100644 Services/Storage/IStorageMetadataRepository.cs create mode 100644 Services/Storage/StorageMetaDataRepository.cs diff --git a/Components/Pages/Storage.razor b/Components/Pages/Storage.razor index 441b946..2858694 100644 --- a/Components/Pages/Storage.razor +++ b/Components/Pages/Storage.razor @@ -46,6 +46,7 @@ Objekte in '@selectedBucket' + @@ -54,11 +55,24 @@ Key Größe Geändert + + @context.Key @context.Size @context.LastModified + + Download + + + @* Name = letzter Segmentteil des Keys *@ + @{ + var fileName = context.Key.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? context.Key; + var encodedName = Uri.EscapeDataString(fileName); + } + Download by Name + @@ -72,6 +86,7 @@ private List? objects; private string? selectedBucket; private string newBucketName = ""; + private string? uploadPath; // optionaler Pfad private const long StreamLimit = 512L * 1024 * 1024; // 512 MB (Program.cs erhöht Multipart-Limit) private HttpClient? Http; @@ -83,7 +98,7 @@ return LoadBuckets(); } - + private async Task LoadBuckets() { @@ -116,10 +131,22 @@ using var content = new MultipartFormDataContent(); content.Add(new StreamContent(stream), "file", file.Name); + if (!string.IsNullOrWhiteSpace(uploadPath)) + content.Add(new StringContent(uploadPath!), "path"); + 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(); + + } + private string GetDownloadUrl(string key) + { + // URL-encode für sichere Übergabe in Catch-all Route + var encodedKey = Uri.EscapeDataString(key); + return $"/api/storage/buckets/{selectedBucket}/download/{encodedKey}"; } + + } diff --git a/Pldpro.Web.csproj b/Pldpro.Web.csproj index 039cf14..66cc46a 100644 --- a/Pldpro.Web.csproj +++ b/Pldpro.Web.csproj @@ -15,5 +15,6 @@ + \ No newline at end of file diff --git a/Program.cs b/Program.cs index a79d776..812a385 100644 --- a/Program.cs +++ b/Program.cs @@ -1,17 +1,20 @@ using Amazon.Runtime; using Amazon.S3; using Microsoft.AspNetCore.Http.Features; +using Microsoft.EntityFrameworkCore.Metadata.Internal; 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.IO; +using System.Net.Http; +using System.Net.Mime; using System.Net.NetworkInformation; using System.Runtime.Intrinsics.Arm; using static MudBlazor.CategoryTypes; using static MudBlazor.Colors; -using System.Net.Http; var builder = WebApplication.CreateBuilder(args); @@ -27,6 +30,10 @@ builder.Services.AddRazorComponents() // HttpClient-Fabrik für serverseitige Komponenten builder.Services.AddHttpClient(); +// MySQL Repository +builder.Services.AddSingleton(); + + // --- S3 / RustFS Settings binding --- builder.Services.Configure(builder.Configuration.GetSection("S3")); @@ -55,6 +62,15 @@ builder.Services.AddScoped(); var app = builder.Build(); + +// Schema sicherstellen (einmalig beim Start) +using (var scope = app.Services.CreateScope()) + { + var repo = scope.ServiceProvider.GetRequiredService(); + await repo.EnsureSchemaAsync(); + } + + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -99,17 +115,65 @@ storage.MapGet("/buckets/{bucket}/objects", async (IStorageService svc, string b }); // Datei in Bucket hochladen (Form-Data: file) -storage.MapPost("/buckets/{bucket}/upload", async (HttpRequest req, IStorageService svc, string bucket) => +storage.MapPost("/buckets/{bucket}/upload", async (HttpRequest req, IStorageService svc, IStorageMetadataRepository meta, string bucket, CancellationToken ct) => { 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"); + + var path = form["path"].ToString(); // optional, z.B. "docs/2026" + path = string.IsNullOrWhiteSpace(path) ? null : path!.Trim().Trim('/'); + + + //await using var stream = file.OpenReadStream(); // Streamlimit über FormOptions konfiguriert + // await svc.UploadObjectAsync(bucket, file.FileName, stream, file.ContentType ?? "application/octet-stream"); + + // Key bauen: optionaler Pfad + Dateiname + var key = string.IsNullOrWhiteSpace(path) ? file.FileName : $"{path}/{file.FileName}"; + await using var stream = file.OpenReadStream(); + var contentType = file.ContentType ?? "application/octet-stream"; + await svc.UploadObjectAsync(bucket, key, stream, contentType, ct); - 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 + // Metadaten persistieren + await meta.UpsertAsync(bucket, file.FileName, path, key, file.Length, contentType, ct); + + return Results.Ok(new { bucket, file = file.FileName, path, key }); + +}).DisableAntiforgery(); // für Blazor XHR Upload + + +// Objekt herunterladen + storage.MapGet("/buckets/{bucket}/download/{*key}", async (IStorageService svc,string bucket,string key,CancellationToken ct) => +{ + // key ist als Catch-all {*key} definiert, damit auch Keys mit "/" (Prefix/Ordner) funktionieren. + var(stream, contentType, length) = await svc.GetObjectAsync(bucket, key, ct); + + // Dateiname aus Key ableiten: + var fileName = key.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? key; + + return Results.File( + fileStream: stream, + contentType: contentType, + fileDownloadName: fileName, + enableRangeProcessing: true, // erlaubt Resume/Teildownloads + lastModified: null, // optional: Last-Modified selbst setzen + entityTag: null); // optional: ETag setzen + }); + +storage.MapGet("/buckets/{bucket}/files/{fileName}/download", async ( + IStorageService svc, + IStorageMetadataRepository meta, + string bucket, + string fileName, + CancellationToken ct) => +{ + var entry = await meta.TryGetAsync(bucket, fileName, ct); + if (entry is null) return Results.NotFound($"No metadata for {bucket}/{fileName}"); + + var (stream, contentType, _) = await svc.GetObjectAsync(bucket, entry.Key, ct); + return Results.File(stream, contentType, fileName, enableRangeProcessing: true); +}); diff --git a/Program.txt b/Program.txt deleted file mode 100644 index 4175989a5bbb96718e5e131ce1150aac29dcb457..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14108 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 diff --git a/Services/IStorageService.cs b/Services/IStorageService.cs index 8640212..5655bbb 100644 --- a/Services/IStorageService.cs +++ b/Services/IStorageService.cs @@ -9,4 +9,5 @@ public interface IStorageService 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); + Task<(Stream Stream, string ContentType, long? ContentLength)> GetObjectAsync(string bucket, string key, CancellationToken ct = default); } diff --git a/Services/S3StorageService.cs b/Services/S3StorageService.cs index 5faf8ca..7a58261 100644 --- a/Services/S3StorageService.cs +++ b/Services/S3StorageService.cs @@ -33,9 +33,11 @@ public sealed class S3StorageService(IAmazonS3 s3) : IStorageService BucketName = bucket, ContinuationToken = token }, ct); - - items.AddRange(resp.S3Objects.Select(o => new ObjectItem(o.Key, o.Size, o.LastModified))); - token = (bool)resp.IsTruncated ? resp.NextContinuationToken : null; + if (resp.S3Objects != null) + { + items.AddRange(resp.S3Objects.Select(o => new ObjectItem(o.Key, o.Size, o.LastModified))); + token = (bool)resp.IsTruncated ? resp.NextContinuationToken : null; + } } while (token is not null); return items; @@ -52,4 +54,24 @@ public sealed class S3StorageService(IAmazonS3 s3) : IStorageService }; await _s3.PutObjectAsync(req, ct); } + + + public async Task<(Stream Stream, string ContentType, long? ContentLength)> GetObjectAsync( + string bucket, string key, CancellationToken ct = default) + { + var resp = await _s3.GetObjectAsync(new GetObjectRequest + { + BucketName = bucket, + Key = key + }, ct); + + // ResponseStream NICHT kopieren, sondern direkt zurückgeben (Server streamt es weiter) + var contentType = string.IsNullOrWhiteSpace(resp.Headers.ContentType) + ? "application/octet-stream" + : resp.Headers.ContentType; + + long? len = resp.Headers.ContentLength >= 0 ? resp.Headers.ContentLength : null; + return (resp.ResponseStream, contentType, len); + } + } diff --git a/Services/Storage/IStorageMetadataRepository.cs b/Services/Storage/IStorageMetadataRepository.cs new file mode 100644 index 0000000..7d590b5 --- /dev/null +++ b/Services/Storage/IStorageMetadataRepository.cs @@ -0,0 +1,20 @@ + +namespace Pldpro.Web.Services; + +public interface IStorageMetadataRepository +{ + Task EnsureSchemaAsync(CancellationToken ct = default); + Task UpsertAsync(string bucket, string fileName, string? path, string key, long? size, string? contentType, CancellationToken ct = default); + Task TryGetAsync(string bucket, string fileName, CancellationToken ct = default); +} + +public sealed record StorageObject( + long Id, + string Bucket, + string FileName, + string? Path, + string Key, + long? Size, + string? ContentType, + DateTime CreatedUtc +); diff --git a/Services/Storage/StorageMetaDataRepository.cs b/Services/Storage/StorageMetaDataRepository.cs new file mode 100644 index 0000000..3027396 --- /dev/null +++ b/Services/Storage/StorageMetaDataRepository.cs @@ -0,0 +1,93 @@ + +using MySqlConnector; +using System.Data; + +namespace Pldpro.Web.Services; + +public sealed class StorageMetadataRepository : IStorageMetadataRepository +{ + private readonly string _connStr; + public StorageMetadataRepository(IConfiguration cfg) => + _connStr = cfg.GetConnectionString("StorageDb") ?? throw new InvalidOperationException("ConnectionStrings:StorageDb missing"); + + public async Task EnsureSchemaAsync(CancellationToken ct = default) + { + const string sql = """ + CREATE TABLE IF NOT EXISTS storage_objects ( + id BIGINT NOT NULL AUTO_INCREMENT, + bucket VARCHAR(63) NOT NULL, + file_name VARCHAR(255) NOT NULL, + path VARCHAR(768) NULL, + s3_key VARCHAR(1024) NOT NULL, + size BIGINT NULL, + content_type VARCHAR(255) NULL, + created_utc DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_bucket_file (bucket, file_name), + INDEX ix_bucket_path (bucket, path(255)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """; + + await using var conn = new MySqlConnection(_connStr); + await conn.OpenAsync(ct); + await using var cmd = new MySqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task UpsertAsync(string bucket, string fileName, string? path, string key, long? size, string? contentType, CancellationToken ct = default) + { + const string sql = """ + INSERT INTO storage_objects (bucket, file_name, path, s3_key, size, content_type) + VALUES (@bucket, @name, @path, @key, @size, @ct) + ON DUPLICATE KEY UPDATE + path = VALUES(path), + s3_key = VALUES(s3_key), + size = VALUES(size), + content_type = VALUES(content_type); + """; + + await using var conn = new MySqlConnection(_connStr); + await conn.OpenAsync(ct); + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@bucket", bucket); + cmd.Parameters.AddWithValue("@name", fileName); + cmd.Parameters.AddWithValue("@path", (object?)path ?? DBNull.Value); + cmd.Parameters.AddWithValue("@key", key); + cmd.Parameters.AddWithValue("@size", (object?)size ?? DBNull.Value); + cmd.Parameters.AddWithValue("@ct", (object?)contentType ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task TryGetAsync(string bucket, string fileName, CancellationToken ct = default) + { + const string sql = """ + SELECT id, bucket, file_name, path, s3_key, size, content_type, created_utc + FROM storage_objects + WHERE bucket = @bucket AND file_name = @name + LIMIT 1; + """; + + await using var conn = new MySqlConnection(_connStr); + await conn.OpenAsync(ct); + + await using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@bucket", bucket); + cmd.Parameters.AddWithValue("@name", fileName); + + await using var reader = await cmd.ExecuteReaderAsync(CommandBehavior.SingleRow, ct); + if (await reader.ReadAsync(ct)) + { + return new StorageObject( + reader.GetInt64(0), + reader.GetString(1), + reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetInt64(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + reader.GetDateTime(7) + ); + } + return null; + } +} diff --git a/appsettings.json b/appsettings.json index 0be4279..e8df083 100644 --- a/appsettings.json +++ b/appsettings.json @@ -13,6 +13,10 @@ "UseHttp": true, "ForcePathStyle": true, "DefaultBucketPrefix": "pld-" // optional + }, + "ConnectionStrings": { + "StorageDb": "Server=192.168.1.101;Port=3306;Database=pld_storage;User Id=pld_user;Password=pld_user;TreatTinyAsBoolean=false;SslMode=None;CharSet=utf8mb4" } + }