Files
scadalink-design/src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs
Joseph Doherty 0dbc0c02f9 feat(centralui): TransportExport wizard under Design nav group
Implements Task T21 of the Transport feature. A four-step Blazor wizard
(Select → Review → Encrypt → Download) under /design/transport/export,
gated on AuthorizationPolicies.RequireDesign:

  1. Select  — TemplateFolderTree (checkbox-mode) plus flat checkbox
               lists for shared scripts, external systems, DB connections,
               notification lists, SMTP configs, API keys, API methods.
  2. Review  — runs DependencyResolver, surfaces seed vs auto-included.
               "Include all dependencies" toggle re-resolves on flip.
  3. Encrypt — passphrase + confirm with strength meter, secret-count
               warning over the resolved closure, explicit unencrypted
               opt-out path (calls BundleExporter with passphrase=null
               so the audit row tags UnencryptedBundleExport).
  4. Download— calls IBundleExporter.ExportAsync, streams bytes to the
               browser via JS interop (wwwroot/js/transport.js), displays
               filename + size + SHA-256 + encryption status.

Source environment is sourced from new TransportOptions.SourceEnvironment
(bound from ScadaLink:Transport:SourceEnvironment, defaults "scadalink"),
filename pattern scadabundle-{env}-{yyyy-MM-dd-HHmmss}.scadabundle.

Tests (bUnit + policy): step 1 group rendering, step 2 dependency
expansion (Pump composes Motor), step 4 full walkthrough verifying
ExportAsync receives the selected ids + authenticated identity, and a
RequireDesign policy-deny test for users without the Design role. Also
unit-pins the filename-sanitisation contract.
2026-05-24 05:30:16 -04:00

428 lines
16 KiB
C#

using System.Security.Cryptography;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using ScadaLink.CentralUI.Auth;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport;
using ScadaLink.Transport.Export;
namespace ScadaLink.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>ScadaLink:Transport:SourceEnvironment</c>) so a multi-cluster
/// deployment can label its bundles distinctly. Defaults to <c>"scadalink"</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 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();
private List<ApiKey> _apiKeys = new();
private List<ApiMethod> _apiMethods = 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();
private readonly HashSet<int> _selectedApiKeys = new();
private readonly HashSet<int> _selectedApiMethods = 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;
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();
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
}
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
|| _selectedApiKeys.Count > 0
|| _selectedApiMethods.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>
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(),
ApiKeyIds: _selectedApiKeys.ToList(),
ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies);
}
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>
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 = "scadalink";
}
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("scadalinkTransport.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>
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 "scadalink";
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();
_selectedApiKeys.Clear();
_selectedApiMethods.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 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>
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();
}
}