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 4175989..0000000 Binary files a/Program.txt and /dev/null differ 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" } + }