519 lines
21 KiB
C#
519 lines
21 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.JSInterop;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Export;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
|
|
|
|
/// <summary>
|
|
/// Code-behind for the TransportExport wizard (Transport feature, Task T21).
|
|
///
|
|
/// Four-step state machine:
|
|
/// <list type="number">
|
|
/// <item><see cref="ExportWizardStep.Select"/> — pick templates + flat artifact lists.</item>
|
|
/// <item><see cref="ExportWizardStep.Review"/> — show resolved closure (seed + auto-included).</item>
|
|
/// <item><see cref="ExportWizardStep.Encrypt"/> — passphrase + secret-count warning, or explicit unencrypted opt-out.</item>
|
|
/// <item><see cref="ExportWizardStep.Download"/> — call <see cref="IBundleExporter"/>, render the file via JS interop.</item>
|
|
/// </list>
|
|
///
|
|
/// The wizard transitions are linear; "Back" returns to the previous step
|
|
/// without clearing selection state. "Done" on Step 4 resets to Step 1 fresh.
|
|
///
|
|
/// <c>SourceEnvironment</c> is sourced from <see cref="TransportOptions.SourceEnvironment"/>
|
|
/// (bound from <c>ScadaBridge:Transport:SourceEnvironment</c>) so a multi-cluster
|
|
/// deployment can label its bundles distinctly. Defaults to <c>"scadabridge"</c>.
|
|
/// </summary>
|
|
public partial class TransportExport : ComponentBase
|
|
{
|
|
public enum ExportWizardStep
|
|
{
|
|
Select = 1,
|
|
Review = 2,
|
|
Encrypt = 3,
|
|
Download = 4,
|
|
}
|
|
|
|
// ---- Injected services ----
|
|
[Inject] private IBundleExporter BundleExporter { get; set; } = default!;
|
|
[Inject] private ITemplateEngineRepository TemplateRepo { get; set; } = default!;
|
|
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
|
|
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
|
|
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
|
|
[Inject] private ISiteRepository SiteRepo { get; set; } = default!;
|
|
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
|
|
[Inject] private IJSRuntime JS { get; set; } = default!;
|
|
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
|
[Inject] private IOptions<TransportOptions> TransportOptions { get; set; } = default!;
|
|
|
|
// ---- Wizard state ----
|
|
private ExportWizardStep _step = ExportWizardStep.Select;
|
|
private bool _loading = true;
|
|
private string? _errorMessage;
|
|
|
|
// ---- Step 1: source data ----
|
|
private List<Template> _templates = new();
|
|
private List<TemplateFolder> _folders = new();
|
|
private List<SharedScript> _sharedScripts = new();
|
|
private List<ExternalSystemDefinition> _externalSystems = new();
|
|
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
|
private List<NotificationList> _notificationLists = new();
|
|
private List<SmtpConfiguration> _smtpConfigs = new();
|
|
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
|
private List<ApiMethod> _apiMethods = new();
|
|
// M8 (E1): site/instance-scoped export. Sites are listed flat; each site's
|
|
// instances hang off it (loaded eagerly via ISiteRepository.GetInstancesBySiteIdAsync)
|
|
// so the operator can pick a whole site or drill into individual instances.
|
|
private List<Site> _sites = new();
|
|
private readonly Dictionary<int, List<Instance>> _instancesBySiteId = new();
|
|
|
|
// ---- Step 1: selection state ----
|
|
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
|
|
// Templates are selected via the tree; the other artifacts use flat checkbox lists
|
|
// backed by integer-id HashSets so wiring is uniform across categories.
|
|
private readonly HashSet<object> _selectedTemplateKeys = new();
|
|
private readonly HashSet<int> _selectedSharedScripts = new();
|
|
private readonly HashSet<int> _selectedExternalSystems = new();
|
|
private readonly HashSet<int> _selectedDbConnections = new();
|
|
private readonly HashSet<int> _selectedNotificationLists = new();
|
|
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
|
// No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
|
|
private readonly HashSet<int> _selectedApiMethods = new();
|
|
// M8 (E1): site/instance selection backed by entity primary keys, matching the
|
|
// DependencyResolver's SiteIds/InstanceIds (NOT names — the resolver fetches by id).
|
|
private readonly HashSet<int> _selectedSites = new();
|
|
private readonly HashSet<int> _selectedInstances = new();
|
|
// Which site rows are expanded to reveal their instance checkboxes (UI-only).
|
|
private readonly HashSet<int> _expandedSites = new();
|
|
private string _filter = string.Empty;
|
|
private bool _includeDependencies = true;
|
|
|
|
// ---- Step 2: dependency-resolved closure ----
|
|
private ResolvedExport? _resolved;
|
|
private bool _resolving;
|
|
|
|
// ---- Step 3: encryption ----
|
|
private string _passphrase = string.Empty;
|
|
private string _passphraseConfirm = string.Empty;
|
|
private bool _showUnencryptedConfirm;
|
|
private bool _exportUnencrypted;
|
|
private int _secretCount;
|
|
|
|
// ---- Step 4: download result ----
|
|
private string? _downloadFilename;
|
|
private long _downloadSize;
|
|
private string? _downloadSha256;
|
|
private bool _downloadInProgress;
|
|
private string? _downloadError;
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await LoadAllAsync();
|
|
}
|
|
|
|
private async Task LoadAllAsync()
|
|
{
|
|
_loading = true;
|
|
_errorMessage = null;
|
|
try
|
|
{
|
|
_templates = (await TemplateRepo.GetAllTemplatesAsync()).ToList();
|
|
_folders = (await TemplateRepo.GetAllFoldersAsync()).ToList();
|
|
_sharedScripts = (await TemplateRepo.GetAllSharedScriptsAsync()).ToList();
|
|
_externalSystems = (await ExternalRepo.GetAllExternalSystemsAsync()).ToList();
|
|
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
|
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
|
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
|
// Inbound API keys are not transported (re-arch C4) — only methods are loaded.
|
|
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
|
|
|
// M8 (E1): sites + their instances for site/instance-scoped export. Each
|
|
// site's instances are loaded eagerly so the expandable picker has them
|
|
// without a per-click round-trip; sites are ordered by identifier to match
|
|
// the bundle's deterministic site ordering.
|
|
_sites = (await SiteRepo.GetAllSitesAsync())
|
|
.OrderBy(s => s.SiteIdentifier, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
_instancesBySiteId.Clear();
|
|
foreach (var site in _sites)
|
|
{
|
|
var instances = (await SiteRepo.GetInstancesBySiteIdAsync(site.Id))
|
|
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
_instancesBySiteId[site.Id] = instances;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to load export source data: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
// ---- Selection helpers ----
|
|
|
|
/// <summary>
|
|
/// Project the tree's checkbox-keys back to template ids. Template keys are
|
|
/// the strings produced by <c>TemplateTreeNode.Key</c> — folder ids are
|
|
/// excluded (folders aren't directly exportable; their templates are).
|
|
/// </summary>
|
|
private IReadOnlyList<int> SelectedTemplateIds()
|
|
{
|
|
var ids = new List<int>();
|
|
foreach (var key in _selectedTemplateKeys)
|
|
{
|
|
if (key is string s && s.StartsWith("t:", StringComparison.Ordinal)
|
|
&& int.TryParse(s.AsSpan(2), out var id))
|
|
{
|
|
ids.Add(id);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the user has ticked at least one item in any category. Drives
|
|
/// the "Next" button on Step 1.
|
|
/// </summary>
|
|
internal bool HasAnySelection =>
|
|
SelectedTemplateIds().Count > 0
|
|
|| _selectedSharedScripts.Count > 0
|
|
|| _selectedExternalSystems.Count > 0
|
|
|| _selectedDbConnections.Count > 0
|
|
|| _selectedNotificationLists.Count > 0
|
|
|| _selectedSmtpConfigs.Count > 0
|
|
|| _selectedApiMethods.Count > 0
|
|
|| _selectedSites.Count > 0
|
|
|| _selectedInstances.Count > 0;
|
|
|
|
private bool PassphraseValid =>
|
|
!string.IsNullOrEmpty(_passphrase)
|
|
&& _passphrase.Length >= 8
|
|
&& _passphrase == _passphraseConfirm;
|
|
|
|
/// <summary>
|
|
/// Coarse strength score 0-4 based on length + character-class diversity. Used
|
|
/// to colour an inline strength meter; never used to gate the export — the
|
|
/// importer enforces its own strength + lockout policies.
|
|
/// </summary>
|
|
/// <param name="s">The passphrase string to score.</param>
|
|
/// <returns>An integer from 0 (blank) to 4 (long, mixed case, digits, and symbols).</returns>
|
|
internal static int PassphraseStrength(string s)
|
|
{
|
|
if (string.IsNullOrEmpty(s)) return 0;
|
|
var score = 0;
|
|
if (s.Length >= 8) score++;
|
|
if (s.Length >= 16) score++;
|
|
if (s.Any(char.IsUpper) && s.Any(char.IsLower)) score++;
|
|
if (s.Any(char.IsDigit) && s.Any(ch => !char.IsLetterOrDigit(ch))) score++;
|
|
return Math.Min(4, score);
|
|
}
|
|
|
|
// ---- Wizard transitions ----
|
|
|
|
private ExportSelection BuildSelection()
|
|
{
|
|
return new ExportSelection(
|
|
TemplateIds: SelectedTemplateIds(),
|
|
SharedScriptIds: _selectedSharedScripts.ToList(),
|
|
ExternalSystemIds: _selectedExternalSystems.ToList(),
|
|
DatabaseConnectionIds: _selectedDbConnections.ToList(),
|
|
NotificationListIds: _selectedNotificationLists.ToList(),
|
|
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
|
// Inbound API keys are not transported (re-arch C4) — methods only.
|
|
ApiMethodIds: _selectedApiMethods.ToList(),
|
|
IncludeDependencies: _includeDependencies)
|
|
{
|
|
// M8 (E1): site/instance ids feed the resolver's SiteIds/InstanceIds.
|
|
// Set via init-only properties so the positional ctor stays the documented
|
|
// additive shape; the resolver dedups a selected instance against its
|
|
// already-selected owning site.
|
|
SiteIds = _selectedSites.ToList(),
|
|
InstanceIds = _selectedInstances.ToList(),
|
|
};
|
|
}
|
|
|
|
private async Task GoToReviewAsync()
|
|
{
|
|
if (!HasAnySelection) return;
|
|
_resolving = true;
|
|
_errorMessage = null;
|
|
try
|
|
{
|
|
var selection = BuildSelection();
|
|
_resolved = await DepResolver.ResolveAsync(selection, CancellationToken.None);
|
|
_step = ExportWizardStep.Review;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = $"Failed to resolve dependencies: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_resolving = false;
|
|
}
|
|
}
|
|
|
|
private async Task ReresolveAsync()
|
|
{
|
|
// Re-run resolution from Step 2 when the user toggles IncludeDependencies.
|
|
_resolving = true;
|
|
try
|
|
{
|
|
_resolved = await DepResolver.ResolveAsync(BuildSelection(), CancellationToken.None);
|
|
}
|
|
finally
|
|
{
|
|
_resolving = false;
|
|
}
|
|
}
|
|
|
|
private void GoToEncrypt()
|
|
{
|
|
// Recompute the secret-field count from the resolved closure so the
|
|
// warning banner stays honest if the user backed up and re-resolved.
|
|
if (_resolved is not null)
|
|
{
|
|
_secretCount = CountSecrets(_resolved);
|
|
}
|
|
_step = ExportWizardStep.Encrypt;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Count the secret fields that <see cref="BundleSecretEncryptor"/> will
|
|
/// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
|
|
/// knows exactly what an unencrypted export would leak.
|
|
/// </summary>
|
|
/// <param name="resolved">The resolved export closure whose secret fields are counted.</param>
|
|
/// <returns>The total number of non-empty secret fields across all external systems, SMTP configs, and database connections.</returns>
|
|
internal static int CountSecrets(ResolvedExport resolved)
|
|
{
|
|
var count = 0;
|
|
foreach (var es in resolved.ExternalSystems)
|
|
{
|
|
if (!string.IsNullOrEmpty(es.AuthConfiguration)) count++;
|
|
}
|
|
foreach (var smtp in resolved.SmtpConfigs)
|
|
{
|
|
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
|
|
}
|
|
foreach (var db in resolved.DatabaseConnections)
|
|
{
|
|
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private void BackToSelect() => _step = ExportWizardStep.Select;
|
|
private void BackToReview() => _step = ExportWizardStep.Review;
|
|
|
|
private void OpenUnencryptedConfirm()
|
|
{
|
|
_showUnencryptedConfirm = true;
|
|
}
|
|
|
|
private async Task ConfirmUnencryptedExport()
|
|
{
|
|
_showUnencryptedConfirm = false;
|
|
_exportUnencrypted = true;
|
|
await StartExportAsync(passphrase: null);
|
|
}
|
|
|
|
private void CancelUnencryptedConfirm()
|
|
{
|
|
_showUnencryptedConfirm = false;
|
|
}
|
|
|
|
private async Task StartEncryptedExportAsync()
|
|
{
|
|
if (!PassphraseValid) return;
|
|
_exportUnencrypted = false;
|
|
await StartExportAsync(_passphrase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Final export step: invokes <see cref="IBundleExporter.ExportAsync"/>,
|
|
/// captures the bundle bytes, computes a display-side SHA-256 (matching
|
|
/// the manifest's content hash naming), and pushes the file to the browser
|
|
/// via JS interop. Errors surface inline; the page never throws to the user.
|
|
/// </summary>
|
|
private async Task StartExportAsync(string? passphrase)
|
|
{
|
|
_step = ExportWizardStep.Download;
|
|
_downloadInProgress = true;
|
|
_downloadError = null;
|
|
try
|
|
{
|
|
var user = await Auth.GetCurrentUsernameAsync();
|
|
var sourceEnv = TransportOptions.Value.SourceEnvironment;
|
|
if (string.IsNullOrWhiteSpace(sourceEnv))
|
|
{
|
|
sourceEnv = "scadabridge";
|
|
}
|
|
|
|
var selection = BuildSelection();
|
|
await using var stream = await BundleExporter.ExportAsync(
|
|
selection, user, sourceEnv, passphrase, CancellationToken.None);
|
|
|
|
byte[] bytes;
|
|
if (stream is MemoryStream ms)
|
|
{
|
|
bytes = ms.ToArray();
|
|
}
|
|
else
|
|
{
|
|
using var copy = new MemoryStream();
|
|
await stream.CopyToAsync(copy);
|
|
bytes = copy.ToArray();
|
|
}
|
|
|
|
_downloadSize = bytes.LongLength;
|
|
_downloadSha256 = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
|
_downloadFilename = BuildFilename(sourceEnv);
|
|
|
|
var base64 = Convert.ToBase64String(bytes);
|
|
await JS.InvokeVoidAsync("scadabridgeTransport.downloadBundle", _downloadFilename, base64);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_downloadError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
_downloadInProgress = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Filename pattern <c>scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.
|
|
/// Source environment characters are sanitised to a filename-safe alphabet so
|
|
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
|
|
/// browser-rejected filenames.
|
|
/// </summary>
|
|
/// <param name="sourceEnvironment">The environment label to embed in the filename (sanitised to filename-safe characters).</param>
|
|
/// <param name="nowUtc">Timestamp to use for the datetime segment; defaults to <see cref="DateTimeOffset.UtcNow"/> when null.</param>
|
|
/// <returns>A filename of the form <c>scadabundle-{env}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.</returns>
|
|
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
|
|
{
|
|
var safe = SanitizeForFilename(sourceEnvironment);
|
|
var ts = (nowUtc ?? DateTimeOffset.UtcNow).ToString("yyyy-MM-dd-HHmmss");
|
|
return $"scadabundle-{safe}-{ts}.scadabundle";
|
|
}
|
|
|
|
private static string SanitizeForFilename(string input)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input)) return "scadabridge";
|
|
var chars = input.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
|
|
return new string(chars);
|
|
}
|
|
|
|
private void Done()
|
|
{
|
|
// Reset every wizard piece so the operator can immediately start a fresh
|
|
// export without page-refresh-induced data reload.
|
|
_step = ExportWizardStep.Select;
|
|
_selectedTemplateKeys.Clear();
|
|
_selectedSharedScripts.Clear();
|
|
_selectedExternalSystems.Clear();
|
|
_selectedDbConnections.Clear();
|
|
_selectedNotificationLists.Clear();
|
|
_selectedSmtpConfigs.Clear();
|
|
_selectedApiMethods.Clear();
|
|
_selectedSites.Clear();
|
|
_selectedInstances.Clear();
|
|
_expandedSites.Clear();
|
|
_filter = string.Empty;
|
|
_includeDependencies = true;
|
|
_resolved = null;
|
|
_passphrase = string.Empty;
|
|
_passphraseConfirm = string.Empty;
|
|
_exportUnencrypted = false;
|
|
_showUnencryptedConfirm = false;
|
|
_downloadFilename = null;
|
|
_downloadSize = 0;
|
|
_downloadSha256 = null;
|
|
_downloadError = null;
|
|
}
|
|
|
|
// ---- Flat-list filter helpers (search box reuses TemplateFolderTree.Filter for the tree) ----
|
|
private bool MatchesFilter(string name) =>
|
|
string.IsNullOrWhiteSpace(_filter)
|
|
|| name.Contains(_filter, StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static void Toggle(HashSet<int> set, int id, bool value)
|
|
{
|
|
if (value) set.Add(id);
|
|
else set.Remove(id);
|
|
}
|
|
|
|
// ---- Step 1 site/instance helpers (M8 E1) ----
|
|
|
|
/// <summary>Instances loaded for a site, or an empty list when the site has none.</summary>
|
|
private IReadOnlyList<Instance> InstancesFor(int siteId) =>
|
|
_instancesBySiteId.TryGetValue(siteId, out var list) ? list : Array.Empty<Instance>();
|
|
|
|
/// <summary>Expand/collapse a site row's nested instance list (UI-only state).</summary>
|
|
private void ToggleSiteExpanded(int siteId)
|
|
{
|
|
if (!_expandedSites.Remove(siteId))
|
|
{
|
|
_expandedSites.Add(siteId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggle a whole site. Selecting a site lets the resolver pull its instances,
|
|
/// so on select we clear any redundant per-instance ticks for that site; on
|
|
/// deselect we leave individual instances untouched (the operator may still
|
|
/// want a subset). Matches the resolver's dedup semantics.
|
|
/// </summary>
|
|
private void ToggleSite(int siteId, bool value)
|
|
{
|
|
if (value)
|
|
{
|
|
_selectedSites.Add(siteId);
|
|
foreach (var instance in InstancesFor(siteId))
|
|
{
|
|
_selectedInstances.Remove(instance.Id);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_selectedSites.Remove(siteId);
|
|
}
|
|
}
|
|
|
|
// ---- Step 2 grouping helpers ----
|
|
|
|
/// <summary>
|
|
/// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
|
|
/// the auto-included dependencies the resolver pulled in for the user.
|
|
/// </summary>
|
|
/// <typeparam name="T">The element type of the artifact list.</typeparam>
|
|
/// <param name="all">The full resolved list including both seed and auto-included items.</param>
|
|
/// <param name="seed">The set of explicitly selected item ids.</param>
|
|
/// <param name="idOf">Function that extracts the integer id from an item.</param>
|
|
/// <returns>Items from <paramref name="all"/> whose ids are not in <paramref name="seed"/> (auto-included dependencies).</returns>
|
|
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
|
|
{
|
|
return all.Where(x => !seed.Contains(idOf(x))).ToList();
|
|
}
|
|
}
|