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"
}
+
}