Compare commits
8 Commits
0f39159d39
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cae77ef1e3 | |||
| f9fb791dca | |||
| 3ca683d06f | |||
| 61ae8e2a3a | |||
| 1467037e62 | |||
| d333409c19 | |||
| 6008c43fec | |||
| c72f6612fc |
@@ -7,6 +7,10 @@
|
||||
|
||||
<MudNavLink Href="test" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">Test</MudNavLink>
|
||||
<MudNavLink Href="storage" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Cloud">Storage</MudNavLink>
|
||||
|
||||
<MudNavLink Href="/dms" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Dashboard">DMS</MudNavLink>
|
||||
<MudNavLink Href="/dms/list" Icon="@Icons.Material.Filled.List">Dokumente</MudNavLink>
|
||||
<MudNavLink Href="/dms/upload" Icon="@Icons.Material.Filled.Upload">Upload</MudNavLink>
|
||||
</MudNavMenu>
|
||||
|
||||
|
||||
|
||||
167
Components/Pages/DmsDashboard.razor
Normal file
167
Components/Pages/DmsDashboard.razor
Normal file
@@ -0,0 +1,167 @@
|
||||
@page "/dms"
|
||||
@using MudBlazor
|
||||
@using Pldpro.Web.UI.Models
|
||||
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>DMS</PageTitle>
|
||||
|
||||
<MudStack Spacing="3">
|
||||
<!-- Kopfzeile: Titel, Bucket-Auswahl und Aktionen -->
|
||||
<MudStack Row="true" AlignItems=AlignItems.Center Spacing="2">
|
||||
<MudText Typo="Typo.h4">Dokumenten-Management</MudText>
|
||||
<MudSpacer />
|
||||
<MudSelect T="string" @bind-Value="_bucket" Label="Bucket" Dense="true" Style="min-width:240px">
|
||||
@foreach (var b in _buckets)
|
||||
{
|
||||
<MudSelectItem Value="@b">@b</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="Reload" StartIcon="@Icons.Material.Filled.Refresh">
|
||||
Aktualisieren
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="@(() => Nav.NavigateTo("/dms/upload"))"
|
||||
StartIcon="@Icons.Material.Filled.CloudUpload">
|
||||
Upload
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
OnClick="@(() => Nav.NavigateTo("/dms/list"))"
|
||||
StartIcon="@Icons.Material.Filled.List">
|
||||
Zur Liste
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<!-- Kennzahlen -->
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudPaper Class="pa-4">
|
||||
<MudText Typo="Typo.subtitle1">Gesamt</MudText>
|
||||
<MudText Typo="Typo.h5">@_total.ToString("N0")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudPaper Class="pa-4">
|
||||
<MudText Typo="Typo.subtitle1">Eingegangen</MudText>
|
||||
<MudText Typo="Typo.h5">@_eingegangen.ToString("N0")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudPaper Class="pa-4">
|
||||
<MudText Typo="Typo.subtitle1">Freigegeben</MudText>
|
||||
<MudText Typo="Typo.h5">@_freigegeben.ToString("N0")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudPaper Class="pa-4">
|
||||
<MudText Typo="Typo.subtitle1">Bezahlt</MudText>
|
||||
<MudText Typo="Typo.h5">@_bezahlt.ToString("N0")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<!-- Zuletzt hinzugefügt + Filter -->
|
||||
<MudPaper Class="pa-4">
|
||||
<MudStack Row="true" AlignItems=AlignItems.Center Spacing="2">
|
||||
<MudText Typo="Typo.h6">Zuletzt hinzugefügt</MudText>
|
||||
<MudSpacer />
|
||||
<MudTextField @bind-Value="_query"
|
||||
Placeholder="Suche (optional)"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Immediate="true" />
|
||||
<MudTextField @bind-Value="_prefix" Placeholder="Pfad-Prefix (optional, z. B. rechnungen/2026)" />
|
||||
<MudButton Variant="Variant.Outlined" OnClick="Reload" StartIcon="@Icons.Material.Filled.Search">
|
||||
Filtern
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTable Items="_latest" Dense="true" Hover="true" Class="mt-3">
|
||||
<HeaderContent>
|
||||
<MudTh>Datei</MudTh>
|
||||
<MudTh>Pfad</MudTh>
|
||||
<MudTh>Größe</MudTh>
|
||||
<MudTh>Geändert</MudTh>
|
||||
<MudTh></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.FileName</MudTd>
|
||||
<MudTd>@context.PathPrefix</MudTd>
|
||||
<MudTd>@(context.Size?.ToString("N0"))</MudTd>
|
||||
<MudTd>@context.LastModified</MudTd>
|
||||
<MudTd Align="TableCellAlign.Right">
|
||||
<MudTooltip Text="Details">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Description"
|
||||
OnClick="@(() => Nav.NavigateTo($"/dms/detail/{Uri.EscapeDataString(context.Bucket)}/{EncodeKeyForPath(context.Key)}"))" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Download">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Download"
|
||||
OnClick="@(() => Download(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
private List<string> _buckets = new();
|
||||
private string? _bucket;
|
||||
|
||||
private string? _query;
|
||||
private string? _prefix;
|
||||
|
||||
private int _total;
|
||||
private int _eingegangen;
|
||||
private int _freigegeben;
|
||||
private int _bezahlt;
|
||||
|
||||
private List<DocumentListItem> _latest = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_buckets = (await Client.ListBucketsAsync()).ToList();
|
||||
_bucket = _buckets.FirstOrDefault();
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_bucket))
|
||||
{
|
||||
_total = _eingegangen = _freigegeben = _bezahlt = 0;
|
||||
_latest = new();
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kennzahlen: einmal komplette Liste (für kleine Datenmengen ok)
|
||||
var (allItems, total) = await Client.SearchAsync(_bucket!, _query, _prefix, 0, int.MaxValue);
|
||||
_total = total;
|
||||
|
||||
// Aktuell sind Status nur UI-intern; wenn später persistiert, hier echte Counts laden.
|
||||
_eingegangen = allItems.Count(i => i.Status == DocumentStatus.Eingegangen);
|
||||
_freigegeben = allItems.Count(i => i.Status == DocumentStatus.Freigegeben);
|
||||
_bezahlt = allItems.Count(i => i.Status == DocumentStatus.Bezahlt);
|
||||
|
||||
// Letzte 10
|
||||
var (latest, _) = await Client.SearchAsync(_bucket!, _query, _prefix, 0, 10);
|
||||
_latest = latest
|
||||
.OrderByDescending(i => i.LastModified ?? DateTime.MinValue)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void Download(DocumentListItem item)
|
||||
{
|
||||
var url = Client.GetDownloadUrl(item.Bucket, item.Key);
|
||||
Nav.NavigateTo(url, forceLoad: true);
|
||||
}
|
||||
|
||||
private static string EncodeKeyForPath(string key)
|
||||
=> string.Join("/", (key ?? string.Empty)
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
}
|
||||
159
Components/Pages/DmsDetail.razor
Normal file
159
Components/Pages/DmsDetail.razor
Normal file
@@ -0,0 +1,159 @@
|
||||
@page "/dms/detail/{Bucket}/{*Key}"
|
||||
@using MudBlazor
|
||||
@using Pldpro.Web.UI.Models
|
||||
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||
@inject NavigationManager Nav
|
||||
@inject IDialogService Dialogs
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Dokument</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems=AlignItems.Center Justify=Justify.Center Class="pa-6">
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (_doc is null)
|
||||
{
|
||||
<MudPaper Class="pa-4">
|
||||
<MudAlert Severity="Severity.Error">
|
||||
Dokument nicht gefunden.
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="Back" Class="mt-3">Zurück</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<!-- Kopfzeile -->
|
||||
<MudStack Row="true" AlignItems=AlignItems.Center Spacing="2">
|
||||
<MudText Typo="Typo.h5">@_doc.FileName</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
OnClick="Back">
|
||||
Zurück
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudGrid>
|
||||
<!-- Vorschau (Platzhalter) -->
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-3" Elevation="1">
|
||||
<MudText Typo="Typo.subtitle1">Vorschau</MudText>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudAlert Severity="Severity.Info">
|
||||
PDF‑Vorschau Platzhalter – später PDF.js/Viewer integrieren.
|
||||
</MudAlert>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- Details & Aktionen -->
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Class="pa-3" Elevation="1">
|
||||
<MudText Typo="Typo.subtitle1">Details</MudText>
|
||||
<MudDivider Class="my-2" />
|
||||
|
||||
<!-- Nicht-generische Tabelle -->
|
||||
<MudSimpleTable Dense="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feld</th>
|
||||
<th>Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Bucket</td><td>@_doc.Bucket</td></tr>
|
||||
<tr><td>Key</td><td>@_doc.Key</td></tr>
|
||||
<tr><td>Dateiname</td><td>@_doc.FileName</td></tr>
|
||||
<tr><td>Pfad</td><td>@_doc.PathPrefix</td></tr>
|
||||
<tr><td>Größe</td><td>@(_doc.Size?.ToString("N0")) Bytes</td></tr>
|
||||
<tr><td>Geändert</td><td>@_doc.LastModified</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
|
||||
<MudStack Row="true" Spacing="1" Class="mt-3">
|
||||
<MudButton Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.Download"
|
||||
OnClick="Download">
|
||||
Download
|
||||
</MudButton>
|
||||
|
||||
<MudButton Color="Color.Error"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
OnClick="Delete">
|
||||
Löschen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Bucket { get; set; } = default!;
|
||||
[Parameter] public string Key { get; set; } = default!; // Catch-all wird von Blazor decodiert
|
||||
|
||||
private DocumentDetail? _doc;
|
||||
private bool _loading;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_doc = await Client.GetAsync(Bucket, Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler beim Laden: {ex.Message}", Severity.Error);
|
||||
_doc = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Back() => Nav.NavigateTo("/dms/list");
|
||||
|
||||
private Task Download()
|
||||
{
|
||||
if (_doc is null) return Task.CompletedTask;
|
||||
var url = Client.GetDownloadUrl(_doc.Bucket, _doc.Key);
|
||||
Nav.NavigateTo(url, forceLoad: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task Delete()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
|
||||
// Du kannst alternativ ShowMessageBox verwenden, wenn du keinen eigenen ConfirmDialog nutzen willst:
|
||||
var confirm = await Dialogs.ShowMessageBox(
|
||||
title: "Dokument löschen",
|
||||
markupMessage: (MarkupString)$"Möchten Sie '<b>{_doc.FileName}</b>' endgültig löschen?",
|
||||
yesText: "Löschen",
|
||||
cancelText: "Abbrechen",
|
||||
options: new DialogOptions { CloseOnEscapeKey = true });
|
||||
|
||||
if (confirm == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Client.DeleteAsync(_doc.Bucket, _doc.Key);
|
||||
Snackbar.Add("Dokument gelöscht.", Severity.Success);
|
||||
Nav.NavigateTo("/dms/list");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Löschen fehlgeschlagen: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Components/Pages/DmsList.razor
Normal file
94
Components/Pages/DmsList.razor
Normal file
@@ -0,0 +1,94 @@
|
||||
@page "/dms/list"
|
||||
@using MudBlazor
|
||||
@using Pldpro.Web.UI.Models
|
||||
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<MudPaper Class="pa-4">
|
||||
<MudStack Row="true" Spacing="2" AlignItems=AlignItems.Center>
|
||||
<MudSelect T="string" @bind-Value="_bucket" Label="Bucket" Dense="true" Style="min-width:220px" Required="true">
|
||||
@foreach (var b in _buckets)
|
||||
{
|
||||
<MudSelectItem Value="@b">@b</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_prefix" Placeholder="Pfad-Prefix (optional, z. B. rechnungen/2026)" />
|
||||
<MudTextField @bind-Value="_query" Placeholder="Suche (Datei/Ordner)"
|
||||
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Immediate="true" />
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Outlined" OnClick="Reload" StartIcon="@Icons.Material.Filled.Search">Suchen</MudButton>
|
||||
<MudButton Variant="Variant.Filled" OnClick="@(() => Nav.NavigateTo("/dms/upload"))"
|
||||
StartIcon="@Icons.Material.Filled.CloudUpload">Upload</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<!-- WICHTIG: T="DocumentListItem" angeben -->
|
||||
<MudTable T="DocumentListItem" Items="_items" Dense="true" Hover="true" Class="mt-3">
|
||||
<HeaderContent>
|
||||
<MudTh>Datei</MudTh>
|
||||
<MudTh>Bucket</MudTh>
|
||||
<MudTh>Pfad</MudTh>
|
||||
<MudTh>Größe</MudTh>
|
||||
<MudTh>Geändert</MudTh>
|
||||
<MudTh></MudTh>
|
||||
</HeaderContent>
|
||||
|
||||
<!-- @context ist vom Typ DocumentListItem -->
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Datei">@context.FileName</MudTd>
|
||||
<MudTd DataLabel="Bucket">@context.Bucket</MudTd>
|
||||
<MudTd DataLabel="Pfad">@context.PathPrefix</MudTd>
|
||||
<MudTd DataLabel="Größe">@context.Size?.ToString("N0")</MudTd>
|
||||
<MudTd DataLabel="Geändert">@context.LastModified</MudTd>
|
||||
<MudTd Align="TableCellAlign.Right">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Nav.NavigateTo($"/dms/detail/{Uri.EscapeDataString(context.Bucket)}/{EncodeKeyForPath(context.Key)}"))">
|
||||
Details
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new int[] { 10, 25, 50 }" @bind-PageSize="_pageSize" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
|
||||
<MudStack Row="true" Spacing="1" AlignItems=AlignItems.Center Justify=Justify.Center Class="mt-2">
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(_page == 0)" OnClick="Prev">Zurück</MudButton>
|
||||
<MudText>Seite @(_page + 1)</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Disabled="@(((_page + 1) * _pageSize) >= _total)" OnClick="Next">Weiter</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<string> _buckets = new();
|
||||
private string? _bucket;
|
||||
private string? _prefix;
|
||||
private string? _query;
|
||||
|
||||
private int _page = 0, _pageSize = 25, _total = 0;
|
||||
private List<DocumentListItem> _items = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_buckets = (await Client.ListBucketsAsync()).ToList();
|
||||
_bucket = _buckets.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(_bucket))
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private async Task Reload()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_bucket)) return;
|
||||
var (items, total) = await Client.SearchAsync(_bucket!, _query, _prefix, _page, _pageSize);
|
||||
_items = items.ToList(); // <- Items ist List<DocumentListItem>
|
||||
_total = total;
|
||||
}
|
||||
|
||||
private async Task Prev() { if (_page > 0) { _page--; await Reload(); } }
|
||||
private async Task Next() { if (((_page + 1) * _pageSize) < _total) { _page++; await Reload(); } }
|
||||
|
||||
private static string EncodeKeyForPath(string key)
|
||||
=> string.Join("/", (key ?? string.Empty)
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
}
|
||||
94
Components/Pages/DmsUpload.razor
Normal file
94
Components/Pages/DmsUpload.razor
Normal file
@@ -0,0 +1,94 @@
|
||||
@page "/dms/upload"
|
||||
@using MudBlazor
|
||||
@inject Pldpro.Web.UI.Services.IDocumentClient Client
|
||||
@inject NavigationManager Nav
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Upload</PageTitle>
|
||||
|
||||
<MudPaper Class="pa-4">
|
||||
<MudText Typo="Typo.h5" GutterBottom="true">Dokument-Upload</MudText>
|
||||
|
||||
<MudStack Spacing="2">
|
||||
<MudSelect T="string" @bind-Value="_bucket" Label="Bucket" Dense="true" Style="min-width:260px" Required="true" RequiredError="Bitte Bucket wählen">
|
||||
@foreach (var b in _buckets)
|
||||
{
|
||||
<MudSelectItem Value="@b">@b</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="_path"
|
||||
Placeholder="Pfad (optional, z. B. rechnungen/2026)"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Folder" />
|
||||
|
||||
<InputFile OnChange="OnFilesSelected" multiple />
|
||||
|
||||
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var msg in _messages)
|
||||
{
|
||||
<MudListItem>@msg</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
|
||||
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.List" OnClick="@(() => Nav.NavigateTo("/dms/list"))">
|
||||
Zur Liste
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Dashboard" OnClick="@(() => Nav.NavigateTo("/dms"))">
|
||||
Dashboard
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private List<string> _buckets = new();
|
||||
private string? _bucket;
|
||||
private string? _path;
|
||||
|
||||
private RenderFragment? _result;
|
||||
private const long StreamLimit = 512L * 1024 * 1024;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_buckets = (await Client.ListBucketsAsync()).ToList();
|
||||
_bucket = _buckets.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Buckets konnten nicht geladen werden: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private readonly List<string> _messages = new();
|
||||
|
||||
private async Task OnFilesSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_bucket))
|
||||
{
|
||||
Snackbar.Add("Bitte zuerst einen Bucket auswählen.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var f in e.GetMultipleFiles())
|
||||
{
|
||||
try
|
||||
{
|
||||
await Client.UploadAsync(_bucket!, _path, f, StreamLimit);
|
||||
_messages.Add($"hochgeladen: {f.Name} ({f.Size:N0} Bytes)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_messages.Add($"Fehler bei '{f.Name}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
@page "/storage"
|
||||
@inject HttpClient Http
|
||||
@inject IHttpClientFactory HttpFactory
|
||||
@inject NavigationManager Nav
|
||||
@using System.Net.Http.Json
|
||||
@using Pldpro.Web.Models
|
||||
|
||||
@@ -9,14 +10,14 @@
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">S3 Storage</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="0">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="center">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudText Typo="Typo.h6">Buckets</MudText>
|
||||
<MudSpacer />
|
||||
<MudTextField @bind-Value="newBucketName" Placeholder="neuer Bucketname" Variant="Variant.Outlined" />
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="CreateBucket">Erstellen</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudList Dense="true" Class="mt-2">
|
||||
<MudList T="BucketVm" Dense="true" Class="mt-2">
|
||||
@if (buckets is null)
|
||||
{
|
||||
<MudListItem>(lädt...)</MudListItem>
|
||||
@@ -42,9 +43,10 @@
|
||||
@if (!string.IsNullOrEmpty(selectedBucket))
|
||||
{
|
||||
<MudPaper Class="pa-4" Elevation="0">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="center">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudText Typo="Typo.h6">Objekte in '@selectedBucket'</MudText>
|
||||
<MudSpacer />
|
||||
<MudTextField @bind-Value="uploadPath" Placeholder="Pfad (optional, z.B. docs/2026)" Variant="Variant.Outlined" />
|
||||
<InputFile OnChange="OnFilesSelected" />
|
||||
</MudStack>
|
||||
|
||||
@@ -53,15 +55,36 @@
|
||||
<MudTh>Key</MudTh>
|
||||
<MudTh>Größe</MudTh>
|
||||
<MudTh>Geändert</MudTh>
|
||||
<MudTh></MudTh>
|
||||
<MudTh></MudTh>
|
||||
<MudTh></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="Größe">@context.Size</MudTd>
|
||||
<MudTd DataLabel="Geändert">@context.LastModified</MudTd>
|
||||
<MudTd>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" Href="@GetDownloadUrl(context.Key)" Target="_blank" StartIcon="@Icons.Material.Filled.Download">Download</MudButton>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@* Name = letzter Segmentteil des Keys *@
|
||||
@{
|
||||
var fileName = context.Key.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? context.Key;
|
||||
var encodedName = Uri.EscapeDataString(fileName);
|
||||
}
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="@($"/api/storage/buckets/{selectedBucket}/files/{encodedName}/download")" Target="_blank" StartIcon="@Icons.Material.Filled.Download">Download by Name</MudButton>
|
||||
</MudTd>
|
||||
|
||||
<MudTd>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(async () => await ConfirmAndDelete(context.Key))">
|
||||
Löschen
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private record BucketVm(string Name, DateTime? CreationDate);
|
||||
@@ -71,10 +94,19 @@
|
||||
private List<ObjectVm>? 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)
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
=> await LoadBuckets();
|
||||
private HttpClient? Http;
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
Http = HttpFactory.CreateClient();
|
||||
Http.BaseAddress = new Uri(Nav.BaseUri); // für relative URLs wie "/api/storage/..."
|
||||
return LoadBuckets();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task LoadBuckets()
|
||||
{
|
||||
@@ -107,10 +139,70 @@
|
||||
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<List<ObjectVm>>($"/api/storage/buckets/{selectedBucket}/objects") ?? new();
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static string EncodeKeyForPath(string key)
|
||||
=> string.Join("/", (key ?? string.Empty)
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
|
||||
private string GetDownloadUrl(string key)
|
||||
{
|
||||
|
||||
// JEDES Segment encodieren, Slash erhalten -> /a/b/c
|
||||
var encodedPath = EncodeKeyForPath(key);
|
||||
return $"/api/storage/buckets/{selectedBucket}/download/{encodedPath}";
|
||||
|
||||
}
|
||||
|
||||
|
||||
private async Task ConfirmAndDelete(string key)
|
||||
{
|
||||
// Simple Bestätigung; alternativ MudDialog verwenden
|
||||
var really = await JSConfirm($"Objekt löschen?\n\nBucket: {selectedBucket}\nKey: {key}");
|
||||
if (really)
|
||||
{
|
||||
await DeleteObject(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task DeleteObject(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(selectedBucket)) return;
|
||||
|
||||
|
||||
var encodedPath = EncodeKeyForPath(key);
|
||||
var url = $"/api/storage/buckets/{selectedBucket}/objects/{encodedPath}";
|
||||
|
||||
|
||||
var resp = await Http!.DeleteAsync(url);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
// Liste aktualisieren
|
||||
objects = await Http!.GetFromJsonAsync<List<ObjectVm>>($"/api/storage/buckets/{selectedBucket}/objects") ?? new();
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
var msg = await resp.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException($"Delete fehlgeschlagen: {(int)resp.StatusCode} {resp.ReasonPhrase}\n{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sehr einfache JS-Confirm-Hilfe (füge IJSRuntime-Injection hinzu)
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
private async Task<bool> JSConfirm(string message)
|
||||
=> await JS.InvokeAsync<bool>("confirm", message);
|
||||
}
|
||||
|
||||
|
||||
18
Components/shared/ConfirmDialog.razor
Normal file
18
Components/shared/ConfirmDialog.razor
Normal file
@@ -0,0 +1,18 @@
|
||||
@using MudBlazor
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.h6">@Title</MudText>
|
||||
<MudText Class="mt-2">@Message</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="() => MudDialog.Cancel()" Variant="Variant.Outlined" Color="Color.Default">Abbrechen</MudButton>
|
||||
<MudButton OnClick="() => MudDialog.Close(DialogResult.Ok(true))" Variant="Variant.Filled" Color="Color.Error">OK</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
[Parameter] public string Title { get; set; } = "Bestätigen";
|
||||
[Parameter] public string Message { get; set; } = "Sind Sie sicher?";
|
||||
}
|
||||
``
|
||||
32
Components/shared/StatusChip.razor
Normal file
32
Components/shared/StatusChip.razor
Normal file
@@ -0,0 +1,32 @@
|
||||
@using Pldpro.Web.UI.Models
|
||||
@using MudBlazor
|
||||
|
||||
<MudChip T="string"
|
||||
Value="@Text"
|
||||
Color="@Color"
|
||||
Variant="Variant.Filled"
|
||||
Label="true"
|
||||
DisableRipple="true"
|
||||
Clickable="false">
|
||||
@Text
|
||||
</MudChip>
|
||||
|
||||
@code {
|
||||
[Parameter] public DocumentStatus Status { get; set; }
|
||||
|
||||
private string Text => Status switch
|
||||
{
|
||||
DocumentStatus.Eingegangen => "Eingegangen",
|
||||
DocumentStatus.Freigegeben => "Freigegeben",
|
||||
DocumentStatus.Bezahlt => "Bezahlt",
|
||||
_ => Status.ToString()
|
||||
};
|
||||
|
||||
private Color Color => Status switch
|
||||
{
|
||||
DocumentStatus.Eingegangen => Color.Info,
|
||||
DocumentStatus.Freigegeben => Color.Success,
|
||||
DocumentStatus.Bezahlt => Color.Primary,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
20
Models/UI/DocumentModels.cs
Normal file
20
Models/UI/DocumentModels.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Pldpro.Web.UI.Models;
|
||||
|
||||
public enum DocumentStatus { Eingegangen, Freigegeben, Bezahlt }
|
||||
|
||||
public class DocumentListItem
|
||||
{
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
public string Key { get; set; } = string.Empty; // z.B. "rechnungen/2026/INV-123.pdf"
|
||||
public string FileName => Key.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? Key;
|
||||
public long? Size { get; set; }
|
||||
public DateTime? LastModified { get; set; }
|
||||
public DocumentStatus Status { get; set; } = DocumentStatus.Eingegangen; // (UI-only)
|
||||
public string PathPrefix => Key.Contains('/') ? string.Join('/', Key.Split('/').SkipLast(1)) : string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DocumentDetail : DocumentListItem
|
||||
{
|
||||
// Platzhalter für spätere Rechnungsfelder
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -15,5 +15,11 @@
|
||||
<PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" />
|
||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
|
||||
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.2.0" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.*" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Components\Shared\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
97
Program.cs
97
Program.cs
@@ -1,12 +1,16 @@
|
||||
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;
|
||||
@@ -22,6 +26,16 @@ builder.Services.AddMudServices();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddServerSideBlazor()
|
||||
.AddCircuitOptions(options => options.DetailedErrors = true);
|
||||
|
||||
// HttpClient-Fabrik für serverseitige Komponenten
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// MySQL Repository
|
||||
builder.Services.AddSingleton<IStorageMetadataRepository, StorageMetadataRepository>();
|
||||
|
||||
|
||||
|
||||
// --- S3 / RustFS Settings binding ---
|
||||
builder.Services.Configure<S3Settings>(builder.Configuration.GetSection("S3"));
|
||||
@@ -47,9 +61,18 @@ builder.Services.AddSingleton<IAmazonS3>(sp =>
|
||||
|
||||
// Domain-Service
|
||||
builder.Services.AddScoped<IStorageService, S3StorageService>();
|
||||
|
||||
builder.Services.AddScoped<Pldpro.Web.UI.Services.IDocumentClient, Pldpro.Web.UI.Services.StorageDocumentClient>();
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
// Schema sicherstellen (einmalig beim Start)
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IStorageMetadataRepository>();
|
||||
await repo.EnsureSchemaAsync();
|
||||
}
|
||||
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -94,17 +117,79 @@ 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");
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
|
||||
storage.MapDelete("/buckets/{bucket}/objects/{*key}", async(
|
||||
IStorageService svc,
|
||||
IStorageMetadataRepository meta,
|
||||
string bucket,
|
||||
string key,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await svc.DeleteObjectAsync(bucket, key);
|
||||
await meta.DeleteByKeyAsync(bucket, key, ct);
|
||||
return Results.NoContent();
|
||||
}).DisableAntiforgery();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
Program.txt
BIN
Program.txt
Binary file not shown.
BIN
Services.txt
Normal file
BIN
Services.txt
Normal file
Binary file not shown.
@@ -9,4 +9,6 @@ public interface IStorageService
|
||||
Task CreateBucketAsync(string bucketName, CancellationToken ct = default);
|
||||
Task<IEnumerable<ObjectItem>> ListObjectsAsync(string bucket, CancellationToken ct = default);
|
||||
Task UploadObjectAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default);
|
||||
Task DeleteObjectAsync(string bucket, string key);
|
||||
Task<(Stream Stream, string ContentType, long? ContentLength)> GetObjectAsync(string bucket, string key, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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 = 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,35 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
public async Task DeleteObjectAsync(string bucket, string key)
|
||||
{
|
||||
// S3-Delete ist idempotent: 204 auch wenn das Objekt nicht existiert.
|
||||
await _s3.DeleteObjectAsync(new Amazon.S3.Model.DeleteObjectRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
21
Services/Storage/IStorageMetadataRepository.cs
Normal file
21
Services/Storage/IStorageMetadataRepository.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
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<StorageObject?> TryGetAsync(string bucket, string fileName, CancellationToken ct = default);
|
||||
Task DeleteByKeyAsync(string bucket, string key, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record StorageObject(
|
||||
long Id,
|
||||
string Bucket,
|
||||
string FileName,
|
||||
string? Path,
|
||||
string Key,
|
||||
long? Size,
|
||||
string? ContentType,
|
||||
DateTime CreatedUtc
|
||||
);
|
||||
108
Services/Storage/StorageMetaDataRepository.cs
Normal file
108
Services/Storage/StorageMetaDataRepository.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
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<StorageObject?> 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;
|
||||
}
|
||||
|
||||
public async Task DeleteByKeyAsync(string bucket, string key, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM storage_objects
|
||||
WHERE bucket = @bucket AND s3_key = @key;
|
||||
""";
|
||||
await using var conn = new MySqlConnector.MySqlConnection(_connStr);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new MySqlConnector.MySqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("@bucket", bucket);
|
||||
cmd.Parameters.AddWithValue("@key", key);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
}
|
||||
20
Services/UI/IDocumentClient.cs
Normal file
20
Services/UI/IDocumentClient.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Pldpro.Web.UI.Models;
|
||||
|
||||
namespace Pldpro.Web.UI.Services;
|
||||
|
||||
public interface IDocumentClient
|
||||
{
|
||||
Task<IReadOnlyList<string>> ListBucketsAsync(CancellationToken ct = default);
|
||||
|
||||
Task<(IReadOnlyList<DocumentListItem> Items, int Total)> SearchAsync(
|
||||
string bucket, string? query, string? pathPrefix, int page, int pageSize, CancellationToken ct = default);
|
||||
|
||||
Task<DocumentDetail?> GetAsync(string bucket, string key, CancellationToken ct = default);
|
||||
|
||||
Task UploadAsync(string bucket, string? pathPrefix, IBrowserFile file, long streamLimit, CancellationToken ct = default);
|
||||
|
||||
Task DeleteAsync(string bucket, string key, CancellationToken ct = default);
|
||||
|
||||
string GetDownloadUrl(string bucket, string key);
|
||||
}
|
||||
95
Services/UI/StorageDocumentClient.cs
Normal file
95
Services/UI/StorageDocumentClient.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Pldpro.Web.UI.Models;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Pldpro.Web.UI.Services;
|
||||
|
||||
public sealed class StorageDocumentClient(IHttpClientFactory factory) : IDocumentClient
|
||||
{
|
||||
private readonly HttpClient _http = factory.CreateClient("AppApi");
|
||||
|
||||
private sealed record BucketVm(string Name, DateTime? CreationDate);
|
||||
private sealed record ObjectVm(string Key, long? Size, DateTime? LastModified);
|
||||
|
||||
public async Task<IReadOnlyList<string>> ListBucketsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var list = await _http.GetFromJsonAsync<List<BucketVm>>("/api/storage/buckets", ct) ?? new();
|
||||
return list.Select(b => b.Name).ToList();
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<DocumentListItem> Items, int Total)> SearchAsync(
|
||||
string bucket, string? query, string? pathPrefix, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var objs = await _http.GetFromJsonAsync<List<ObjectVm>>($"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/objects", ct) ?? new();
|
||||
|
||||
IEnumerable<DocumentListItem> q = objs.Select(o => new DocumentListItem
|
||||
{
|
||||
Bucket = bucket,
|
||||
Key = o.Key,
|
||||
Size = o.Size,
|
||||
LastModified = o.LastModified
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pathPrefix))
|
||||
q = q.Where(d => d.Key.StartsWith(pathPrefix!.Trim('/') + "/", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
q = q.Where(d =>
|
||||
d.FileName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
d.PathPrefix.Contains(query, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var total = q.Count();
|
||||
var pageItems = q
|
||||
.OrderByDescending(d => d.LastModified ?? DateTime.MinValue)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return (pageItems, total);
|
||||
}
|
||||
|
||||
public async Task<DocumentDetail?> GetAsync(string bucket, string key, CancellationToken ct = default)
|
||||
{
|
||||
// Da es keinen Einzel-Endpoint gibt, holen wir die Liste und picken das Objekt.
|
||||
var (items, _) = await SearchAsync(bucket, null, null, 0, int.MaxValue, ct);
|
||||
var d = items.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.Ordinal));
|
||||
return d is null ? null : new DocumentDetail
|
||||
{
|
||||
Bucket = d.Bucket,
|
||||
Key = d.Key,
|
||||
Size = d.Size,
|
||||
LastModified = d.LastModified,
|
||||
Status = d.Status
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UploadAsync(string bucket, string? pathPrefix, IBrowserFile file, long streamLimit, CancellationToken ct = default)
|
||||
{
|
||||
using var stream = file.OpenReadStream(streamLimit);
|
||||
using var content = new MultipartFormDataContent();
|
||||
content.Add(new StreamContent(stream), "file", file.Name);
|
||||
if (!string.IsNullOrWhiteSpace(pathPrefix))
|
||||
content.Add(new StringContent(pathPrefix!.Trim('/')), "path");
|
||||
|
||||
var resp = await _http.PostAsync($"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/upload", content, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string bucket, string key, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/objects/{EncodeKeyForPath(key)}";
|
||||
var resp = await _http.DeleteAsync(url, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public string GetDownloadUrl(string bucket, string key)
|
||||
=> $"/api/storage/buckets/{Uri.EscapeDataString(bucket)}/download/{EncodeKeyForPath(key)}";
|
||||
|
||||
// Pfadsegment-weise encodieren: Slashes bleiben Trennzeichen
|
||||
private static string EncodeKeyForPath(string key)
|
||||
=> string.Join("/", (key ?? string.Empty)
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
}
|
||||
@@ -8,11 +8,15 @@
|
||||
"AllowedHosts": "*",
|
||||
"S3": {
|
||||
"ServiceURL": "http://192.168.1.102:9000",
|
||||
"AccessKey": "your-access-key",
|
||||
"SecretKey": "your-secret-key",
|
||||
"AccessKey": "4ex1dhvyAqin58PT7CBQ",
|
||||
"SecretKey": "6QKaqIjpUWLJBYXnHwgkFlA1N8irtyuf4MRxhcGb",
|
||||
"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"
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user