using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Shell.ViewModels;
/// One saved Spelunk key, as a row on the Security page.
///
/// Carries the decrypted key so the connect path needs no second decryption, and — as the credential row does
/// — exposes nothing of it to the view: the XAML binds the name, the address and a count.
///
internal sealed class SpelunkKeyRowViewModel(VaultItem key, Guid vaultId, string vaultName)
{
internal Guid EntityId => key.EntityId;
internal Guid VaultId { get; } = vaultId;
internal string VaultName { get; } = vaultName;
internal SpelunkKeySecret Key => key.Secret;
internal string Label => key.Secret.Label;
/// The one address this key is ever sent to.
internal string ApiBase => key.Secret.ApiBase;
/// The vault's name, when there is more than one to tell apart; empty otherwise.
internal string VaultBadge { get; init; } = string.Empty;
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// How many hosts reach Spelunk with this key, in words.
internal string UsedBy { get; init; } = string.Empty;
internal bool IsReadOnly => key.IsReadOnly;
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
}
///
/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do.
///
///
///
/// A saved key says where it may go. It is an API key and the one Spelunk address it belongs to, kept
/// in the keychain and synced like any secret. A host can name it where it would name a credential, and it is
/// then sent to that address and never to another — see . A link from that
/// Spelunk connects straight away, because saving the key is the user saying so — see
/// .
///
///
/// The form saves into the personal vault, and a second key for an address the personal vault already has a
/// key for replaces that key rather than adding a rival: one Spelunk, one key, which is also what makes the
/// answer to "which key does a link use?" not depend on anything the user cannot see.
///
///
internal sealed partial class VaultViewModel
{
/// Every Spelunk key this session can read, the personal vault's first.
internal ObservableCollection SpelunkKeys { get; } = [];
/// Whether there is any saved key to list.
internal bool HasSpelunkKeys => SpelunkKeys.Count > 0;
/// The Spelunk API address in the Security page's form, as typed.
[ObservableProperty]
private string spelunkKeyFormApi = string.Empty;
/// The API key in the Security page's form, as pasted. Cleared the moment it is saved.
[ObservableProperty]
private string spelunkKeyFormKey = string.Empty;
/// What the form has to say about the last save, or empty.
///
/// Beside the form rather than only on the status line, because the Security page is a long way from the
/// status bar and a refusal nobody sees reads as a button that does nothing.
///
[ObservableProperty]
private string spelunkKeyFormNote = string.Empty;
/// Whether has anything in it.
internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0;
partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote));
///
/// Picks the saved key for the address being typed, when nothing is chosen yet.
///
///
/// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the
/// address would be choosing for them.
///
partial void OnEditorSpelunkApiChanged(string value)
{
if (EditorSelectedSpelunkKey is not null
|| !SpelunkRoute.TryNormalizeApiBase(value, out var apiBase, out _)
|| SavedSpelunkKeyFor(apiBase, PersonalVaultId()) is not { } saved)
{
return;
}
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == saved.EntityId);
}
/// Saves the key in the form for the address in the form.
[RelayCommand]
private async Task SaveSpelunkKeyAsync(CancellationToken cancellationToken)
{
if (!TryReadSpelunkKeyForm(out var vaultId, out var existing, out var key))
{
return;
}
var apiBase = key.ApiBase;
SpelunkKeyFormNote = string.Empty;
await RunAsync(
"Saving the Spelunk key…",
async () =>
{
if (existing is not null)
{
await session.SpelunkKeys
.UpdateAsync(vaultId, existing.EntityId, key, cancellationToken)
.ConfigureAwait(true);
}
else
{
await session.SpelunkKeys.CreateAsync(vaultId, key, cancellationToken).ConfigureAwait(true);
}
// The key leaves the form as soon as it is in the keychain; the address stays, as a reminder
// of what was just saved.
SpelunkKeyFormKey = string.Empty;
SpelunkKeyFormApi = apiBase;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
var said = existing is null
? $"Saved the Spelunk key for {apiBase}. Links from that Spelunk now connect straight away."
: $"Replaced the Spelunk key for {apiBase}. Every host using it connects with the new one.";
Status = connection() is null ? $"{said} It will sync when you are online." : said;
SpelunkKeyFormNote = said;
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
///
/// Reads the form into the key it would save, and where; or says on the form why it cannot.
///
/// The vault the key goes into: the personal one, when there is one.
/// The key already saved there for that address, which this one replaces.
/// The key to save, keeping the existing one's name.
private bool TryReadSpelunkKeyForm(
out Guid vaultId,
out SpelunkKeyRowViewModel? existing,
[NotNullWhen(true)] out SpelunkKeySecret? key)
{
vaultId = Guid.Empty;
existing = null;
key = null;
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkKeyFormApi, out var apiBase, out var reason))
{
SpelunkKeyFormNote = reason;
return false;
}
if ((PersonalVaultId() ?? WritableActiveVault()) is not { } target)
{
SpelunkKeyFormNote = "There is no vault here this account can write to, so the key cannot be saved.";
return false;
}
vaultId = target;
existing = SpelunkKeys.FirstOrDefault(row =>
row.VaultId == target && string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
var candidate = new SpelunkKeySecret
{
Label = existing?.Label ?? SpelunkKeyLabel(apiBase),
ApiBase = apiBase,
ApiKey = SpelunkKeyFormKey.Trim(),
};
if (!candidate.TryValidate(out reason))
{
SpelunkKeyFormNote = reason;
return false;
}
key = candidate;
return true;
}
/// Asks whether a saved Spelunk key should go.
[RelayCommand]
private void DeleteSpelunkKeyRow(SpelunkKeyRowViewModel? row)
{
if (row is null)
{
return;
}
PendingDeletion = new DeletionRequest(
DeletionTarget.SpelunkKey,
row.EntityId,
$"Delete the Spelunk key for {row.ApiBase}?",
HowFarADeletionGoes("The key"),
HostsReachingSpelunkWith(row.EntityId));
}
/// Queues a tombstone for the Spelunk key that was agreed to.
private async Task DeleteSpelunkKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
{
if (SpelunkKeys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
{
Status = "That Spelunk key is no longer here, so nothing was deleted.";
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.SpelunkKeys
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted the Spelunk key for {row.ApiBase}.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// How many Spelunk keys would not decrypt.
///
/// Read after the hosts, because each row says how many of them use it.
///
private async Task ReloadSpelunkKeysAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var rows = new List();
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.SpelunkKeys
.ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true);
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(item => new SpelunkKeyRowViewModel(item, vault.VaultId, vault.Name)
{
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
UsedBy = UsedBySummary(Hosts.Count(host => host.Host.Spelunk?.ApiKeyCredentialId == item.EntityId)),
}));
}
var personal = PersonalVaultId();
SpelunkKeys.Clear();
foreach (var row in rows
.OrderByDescending(row => row.VaultId == personal)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.ApiBase, StringComparer.Ordinal))
{
SpelunkKeys.Add(row);
}
OnPropertyChanged(nameof(HasSpelunkKeys));
return unreadable;
}
///
/// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password.
///
///
///
/// A saved Spelunk key goes to its own address or nowhere. A host naming one with a different
/// address — edited by hand, or merged from two devices — is refused in words rather than sending the key
/// to a Spelunk it was never saved for. A credential carries no address, so its password goes where the
/// host says, as it always has.
///
///
/// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential
/// is — see .
///
///
/// The key cannot be sent to this host's Spelunk.
private string SpelunkApiKeyFor(HostRowViewModel row, SpelunkRoute route)
{
if (SpelunkKeys.FirstOrDefault(key => key.EntityId == route.ApiKeyCredentialId) is { } saved)
{
if (!string.Equals(saved.ApiBase, route.ApiBase, StringComparison.Ordinal))
{
throw new SpelunkTunnelException(
$"'{row.Label}' is reached through {route.ApiBase} with the Spelunk key saved for "
+ $"{saved.ApiBase}, and that key is never sent anywhere else. Edit the host to choose "
+ "the key for its own Spelunk.");
}
return saved.Key.ApiKey;
}
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is { } credential)
{
return credential.Credential.Password;
}
throw new SpelunkTunnelException(
$"'{row.Label}' is reached through Spelunk with an API key that is not in this keychain any more. "
+ "Edit the host to choose another one, or save the key under Settings → Security.");
}
/// The saved key for a Spelunk, the given vault's first, or null when none is saved.
private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) =>
SpelunkKeys
.Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal))
.OrderByDescending(row => row.VaultId == preferredVault)
.FirstOrDefault();
/// The active vault when this session may write to it, for a session with no personal vault.
private Guid? WritableActiveVault() =>
session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite)
?.VaultId;
/// The name a key saved from the form is given: where it goes.
private static string SpelunkKeyLabel(string apiBase) =>
Uri.TryCreate(apiBase, UriKind.Absolute, out var uri) ? $"Spelunk at {uri.Authority}" : "Spelunk";
private static string UsedBySummary(int hosts) => hosts switch
{
0 => "No host uses it yet",
1 => "1 host uses it",
_ => $"{hosts} hosts use it",
};
/// What the hosts that reach Spelunk with a key would be left with, or nothing when none do.
private string HostsReachingSpelunkWith(Guid entityId)
{
var named = Hosts
.Where(row => row.Host.Spelunk?.ApiKeyCredentialId == entityId)
.Select(row => row.Label)
.ToArray();
if (named.Length == 0)
{
return string.Empty;
}
var list = named.Length <= 3
? string.Join(", ", named)
: $"{string.Join(", ", named.Take(3))} and {named.Length - 3} more";
return named.Length == 1
? $"{list} reaches Spelunk with it, and will refuse to connect until it names another key."
: $"{named.Length} hosts reach Spelunk with it — {list} — and will refuse to connect until they "
+ "name another key.";
}
}