From b6c01dab155b15d9826f6943a9ebab333a1dfae0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 18:22:34 +0200 Subject: [PATCH 1/7] feat: enhance IRC support with display name handling, private channel tracking, and connection identification --- .../UI/Chat/ChatMessageManager.cs | 7 ++- .../UI/ListSources/ChannelListSource.cs | 15 +++++-- src/EchoHub.Client/UI/MainWindow.cs | 9 +++- src/EchoHub.Core/Constants/HubConstants.cs | 6 +++ src/EchoHub.Core/DTOs/ChatDtos.cs | 3 +- src/EchoHub.Core/DTOs/ProfileDtos.cs | 3 +- src/EchoHub.Server.Irc/IrcClientConnection.cs | 3 +- .../Controllers/ChannelsController.cs | 6 ++- src/EchoHub.Server/Services/ChatService.cs | 26 +++++++---- .../Services/PresenceTracker.cs | 15 +++++++ src/EchoHub.Tests/PresenceTrackerTests.cs | 45 +++++++++++++++++++ 11 files changed, 118 insertions(+), 20 deletions(-) diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 6f9a66a..44ac51e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -381,6 +381,9 @@ public sealed class ChatMessageManager private List FormatMessage(MessageDto message) { var time = FormatTime(message.SentAt); + // Show the display name, but keep color + click identity keyed to the username + // so they stay consistent with the user list and profile lookups. + var senderName = message.SenderDisplayName ?? message.SenderUsername; var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor) ?? NickColorHelper.GetAttribute(message.SenderUsername); @@ -394,7 +397,7 @@ public sealed class ChatMessageManager var displayContent = EmojiHelper.ReplaceEmoji(message.Content); var contentLines = displayContent.Split('\n'); - var header = HeaderSegments(time, message.SenderUsername, senderColor); + var header = HeaderSegments(time, senderName, senderColor); header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r'))); lines.Add(new ChatLine(header)); @@ -413,7 +416,7 @@ public sealed class ChatMessageManager 1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]", _ => $"[{attachments.Count} attachments]", }; - var header = HeaderSegments(time, message.SenderUsername, senderColor); + var header = HeaderSegments(time, senderName, senderColor); header.Add(new(summary, null)); lines.Add(new ChatLine(header)); } diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs index 5079c76..0b4ea34 100644 --- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs @@ -18,6 +18,7 @@ public class ChannelListSource : IListDataSource private readonly Dictionary _unreadCounts = []; private readonly HashSet _protectedChannels = []; private readonly HashSet _mentionChannels = []; + private readonly HashSet _privateChannels = []; private string _activeChannel = string.Empty; public event NotifyCollectionChangedEventHandler? CollectionChanged; @@ -32,7 +33,8 @@ public class ChannelListSource : IListDataSource private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None); public void Update(List channels, Dictionary unread, string activeChannel, - IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null) + IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null, + IReadOnlySet? privateChannels = null) { _channelNames.Clear(); _channelNames.AddRange(channels); @@ -45,6 +47,9 @@ public class ChannelListSource : IListDataSource _mentionChannels.Clear(); if (mentionChannels is not null) _mentionChannels.UnionWith(mentionChannels); + _privateChannels.Clear(); + if (privateChannels is not null) + _privateChannels.UnionWith(privateChannels); _activeChannel = activeChannel; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; if (!SuspendCollectionChangedEvent) @@ -67,8 +72,12 @@ public class ChannelListSource : IListDataSource var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var prefix = isActive ? "> " : " "; - // Trailing * marks password-protected (+k) channels - var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}"; + // Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones + var channelText = $"#{name}"; + if (_protectedChannels.Contains(name)) + channelText += "*"; + if (_privateChannels.Contains(name)) + channelText += "~"; var badge = hasUnread ? $" ({unread})" : ""; // Resolve Transparent backgrounds to the view's actual background diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index b5fc39f..94919e6 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1317,8 +1317,11 @@ public sealed partial class MainWindow : Runnable /// private void RefreshChannelList() { + var privateChannels = _channelNames + .Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic) + .ToHashSet(); _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, - _channelProtected, _messageManager.MentionChannels); + _channelProtected, _messageManager.MentionChannels, privateChannels); _channelList.Source = _channelListSource; // Restore selection to current channel @@ -1394,6 +1397,10 @@ public sealed partial class MainWindow : Runnable var text = roleTag.Length > 0 ? $"{statusIcon} {roleTag} {name}" : $"{statusIcon} {name}"; + // Users connected only via the IRC gateway get a tag — they lack client features + // (encryption, attachments, profiles), which is useful context in conversation. + if (u.IsIrc) + text += " [irc]"; // Fall back to the deterministic per-nick palette so user-list colors // match the same user's messages in chat. var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor) diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index a7ead47..101935c 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -4,6 +4,12 @@ public static class HubConstants { public const string ChatHubPath = "/hubs/chat"; public const string DefaultChannel = "general"; + + /// + /// Connection-id prefix for IRC gateway connections. The presence tracker uses it to + /// tell IRC-only users apart from native (SignalR) clients. + /// + public const string IrcConnectionIdPrefix = "irc-"; public const int DefaultHistoryCount = 100; public const int MaxMessageLength = 2000; public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 34d1291..13b4a06 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -10,7 +10,8 @@ public record MessageDto( string ChannelName, DateTimeOffset SentAt, List? Attachments = null, - List? Embeds = null); + List? Embeds = null, + string? SenderDisplayName = null); /// /// A file attached to a message. holds the color-tag art for diff --git a/src/EchoHub.Core/DTOs/ProfileDtos.cs b/src/EchoHub.Core/DTOs/ProfileDtos.cs index b4b28e7..e75e252 100644 --- a/src/EchoHub.Core/DTOs/ProfileDtos.cs +++ b/src/EchoHub.Core/DTOs/ProfileDtos.cs @@ -30,6 +30,7 @@ public record UserPresenceDto( string? NicknameColor, UserStatus Status, string? StatusMessage, - ServerRole Role); + ServerRole Role, + bool IsIrc = false); public record AvatarUploadResponse(string AvatarAscii); diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs index 3b1b8e4..2ec2ff1 100644 --- a/src/EchoHub.Server.Irc/IrcClientConnection.cs +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -1,5 +1,6 @@ using System.Net.Sockets; using System.Text; +using EchoHub.Core.Constants; namespace EchoHub.Server.Irc; @@ -14,7 +15,7 @@ public sealed class IrcClientConnection : IAsyncDisposable private readonly SemaphoreSlim _writeLock = new(1, 1); // Connection identity - public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}"; + public string ConnectionId { get; } = $"{HubConstants.IrcConnectionIdPrefix}{Guid.NewGuid()}"; // Registration state public string? Nickname { get; set; } diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 97dbf89..1889e61 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -308,7 +308,8 @@ public class ChannelsController : ControllerBase sender?.NicknameColor, channelName, message.SentAt, - attachmentDtos); + attachmentDtos, + SenderDisplayName: sender?.DisplayName); await _chatService.BroadcastMessageAsync(channelName, messageDto); @@ -444,7 +445,8 @@ public class ChannelsController : ControllerBase sender?.NicknameColor, channelName, message.SentAt, - [new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]); + [new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))], + SenderDisplayName: sender?.DisplayName); await _chatService.BroadcastMessageAsync(channelName, messageDto); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index f5509ad..889b345 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -121,7 +121,8 @@ public class ChatService : IChatService { presence = new UserPresenceDto( user.Username, user.DisplayName, user.NicknameColor, - user.Status, user.StatusMessage, user.Role); + user.Status, user.StatusMessage, user.Role, + _presenceTracker.IsIrcOnly(user.Username)); } } catch (Exception ex) @@ -236,7 +237,8 @@ public class ChatService : IChatService sender?.NicknameColor, channelName, message.SentAt, - Embeds: embeds); + Embeds: embeds, + SenderDisplayName: sender?.DisplayName); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -279,7 +281,8 @@ public class ChatService : IChatService user.NicknameColor, status, statusMessage, - user.Role); + user.Role, + _presenceTracker.IsIrcOnly(user.Username)); var channels = _presenceTracker.GetChannelsForUser(username); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); @@ -295,16 +298,20 @@ public class ChatService : IChatService using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - return await db.Users + var users = await db.Users .Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible) - .Select(u => new UserPresenceDto( + .ToListAsync(); + + // IsIrcOnly comes from the in-memory tracker, so map outside the EF query + return users.Select(u => new UserPresenceDto( u.Username, u.DisplayName, u.NicknameColor, u.Status, u.StatusMessage, - u.Role)) - .ToListAsync(); + u.Role, + _presenceTracker.IsIrcOnly(u.Username))) + .ToList(); } public Task BroadcastMessageAsync(string channelName, MessageDto message) @@ -380,7 +387,7 @@ public class ChatService : IChatService .Join(db.Users, m => m.SenderUserId, u => u.Id, - (m, u) => new { m, u.NicknameColor }) + (m, u) => new { m, u.NicknameColor, u.DisplayName }) .ToListAsync(); raw.Reverse(); @@ -448,7 +455,8 @@ public class ChatService : IChatService channelName, x.m.SentAt, attachments, - embeds)); + embeds, + x.DisplayName)); } // Lazily delete the pruned messages (+ their attachment rows) as they're encountered. diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index b00e38a..ce5c7b8 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using EchoHub.Core.Constants; namespace EchoHub.Server.Services; @@ -174,6 +175,20 @@ public class PresenceTracker return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; } + /// + /// True when the user is online exclusively through the IRC gateway. A user who also has a + /// native client connected has full features, so they don't count as IRC-only. + /// + public bool IsIrcOnly(string username) + { + lock (_lock) + { + return _userConnections.TryGetValue(username, out var connections) + && connections.Count > 0 + && connections.All(c => c.StartsWith(HubConstants.IrcConnectionIdPrefix, StringComparison.Ordinal)); + } + } + public int GetOnlineUserCount() { return _userConnections.Count; diff --git a/src/EchoHub.Tests/PresenceTrackerTests.cs b/src/EchoHub.Tests/PresenceTrackerTests.cs index de96738..b8c580f 100644 --- a/src/EchoHub.Tests/PresenceTrackerTests.cs +++ b/src/EchoHub.Tests/PresenceTrackerTests.cs @@ -1,3 +1,4 @@ +using EchoHub.Core.Constants; using EchoHub.Server.Services; using Xunit; @@ -65,4 +66,48 @@ public class PresenceTrackerTests Assert.Contains("general", channels); Assert.Contains("random", channels); } + + [Fact] + public void IsIrcOnly_AllConnectionsIrc_ReturnsTrue() + { + var tracker = new PresenceTracker(); + tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", Guid.NewGuid(), "alice"); + Assert.True(tracker.IsIrcOnly("alice")); + } + + [Fact] + public void IsIrcOnly_NativeConnection_ReturnsFalse() + { + var tracker = new PresenceTracker(); + tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); + Assert.False(tracker.IsIrcOnly("alice")); + } + + [Fact] + public void IsIrcOnly_MixedConnections_ReturnsFalse() + { + var tracker = new PresenceTracker(); + var userId = Guid.NewGuid(); + tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", userId, "alice"); + tracker.UserConnected("conn2", userId, "alice"); + Assert.False(tracker.IsIrcOnly("alice")); + } + + [Fact] + public void IsIrcOnly_OfflineUser_ReturnsFalse() + { + var tracker = new PresenceTracker(); + Assert.False(tracker.IsIrcOnly("nobody")); + } + + [Fact] + public void IsIrcOnly_NativeConnectionDisconnects_BecomesTrue() + { + var tracker = new PresenceTracker(); + var userId = Guid.NewGuid(); + tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", userId, "alice"); + tracker.UserConnected("conn2", userId, "alice"); + tracker.UserDisconnected("conn2"); + Assert.True(tracker.IsIrcOnly("alice")); + } } From e439c8ae723ff60cadf306383301f4d5456b94d7 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 18:47:28 +0200 Subject: [PATCH 2/7] feat: implement end-to-end encryption for room keys with secure storage and migration support --- docs/changelog/index.md | 1 + docs/changelog/toc.yml | 2 + docs/changelog/v0.2.14.md | 20 +++ src/Directory.Build.props | 2 +- src/EchoHub.Client/AppOrchestrator.cs | 124 +++++++++++-- src/EchoHub.Client/Config/ClientConfig.cs | 3 +- src/EchoHub.Client/Config/ConfigManager.cs | 3 + src/EchoHub.Client/EchoHub.Client.csproj | 1 + .../Services/ConnectionManager.cs | 11 +- .../Services/EchoHubConnection.cs | 19 ++ .../Services/RoomKeyProtector.cs | 120 +++++++++++++ src/EchoHub.Client/Services/RoomKeyStore.cs | 83 ++++++++- src/EchoHub.Tests/RoomKeyProtectionTests.cs | 168 ++++++++++++++++++ 13 files changed, 531 insertions(+), 26 deletions(-) create mode 100644 docs/changelog/v0.2.14.md create mode 100644 src/EchoHub.Client/Services/RoomKeyProtector.cs create mode 100644 src/EchoHub.Tests/RoomKeyProtectionTests.cs diff --git a/docs/changelog/index.md b/docs/changelog/index.md index ce4d9fe..01022b5 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,7 @@ Release history for EchoHub. ## Releases +- [v0.2.14](v0.2.14.md) - E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish - [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions - [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix - [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 9996ae4..ce61a48 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.14 + href: v0.2.14.md - name: v0.2.13 href: v0.2.13.md - name: v0.2.12 diff --git a/docs/changelog/v0.2.14.md b/docs/changelog/v0.2.14.md new file mode 100644 index 0000000..9cc40c8 --- /dev/null +++ b/docs/changelog/v0.2.14.md @@ -0,0 +1,20 @@ +# v0.2.14 + +A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators. + +## New Features + +- **Room keys encrypted at rest** — the per-channel room keys cached so you don't retype a passphrase every launch are no longer stored as plain base64 in `config.json`. On Windows they're protected with DPAPI (current-user scope); on Linux/macOS with AES-GCM under a per-user key file created with `0600` permissions next to the config. Existing plain entries migrate to the encrypted format automatically on first load. The passphrase itself is never stored in any form. +- **`[irc]` tag in the users panel** — users online only through the IRC gateway are tagged `[irc]`, useful context since IRC clients lack encryption, attachments, and profiles. Someone also running the TUI shows untagged. +- **`~` marker for private channels** — the channel list now marks private (unlisted) channels with a trailing `~`, alongside the existing `*` for password-protected ones (`#room*~` when both apply). + +## Bug Fixes + +- **Locked encrypted channels now prompt for the passphrase.** Auto-joining your channels at connect silently entered end-to-end encrypted rooms you're a member of without running the unlock flow — on a new device (or after a cancelled prompt) the room showed only `[encrypted — rejoin this channel with its passphrase to unlock]` placeholders, and only a manual `/join` recovered it. Selecting the channel now offers the passphrase prompt; entering it unlocks history and live messages in place. Cancelling is remembered for the session so reselecting the channel doesn't nag — `/join` or trying to send always re-offers the prompt. +- **A stale cached room key no longer beats a fresh one.** If an encrypted channel was deleted and recreated under the same name, a client that still had the old key cached kept encrypting messages nobody else could read. Typing the passphrase on join now always adopts the key from the server's current envelope, replacing the stale cache. +- **IRC clients no longer get flooded with ciphertext for image messages.** The gateway forwarded image ASCII previews without stripping transport encryption, spamming IRC clients with one enormous `$ENC$v1$…` line per image. Previews are now decrypted before formatting, and any that still can't be read (e.g. end-to-end room ciphertext the server cannot decrypt) are skipped in favor of the plain `[Image: name] url` line. +- **Display names now show on chat messages.** Messages only carried the sender's username, so a configured display name appeared in the user list but not on the messages themselves. Live messages, history, and attachment messages now all carry it; mention and profile lookups stay keyed to the username. + +## Security + +- **No plaintext can leak into an encrypted room.** Previously, a client without the room key silently sent unencrypted text into an end-to-end encrypted channel (and other members saw it as a normal message, none the wiser it went over the wire readable by the server). All send paths — typed messages, staged file attachments, and URL sends — are now blocked while a room is locked: the client offers the unlock prompt, keeps staged files in the tray, and refuses to transmit until the key is present, with a hard guard at the connection layer as backstop. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0919c4b..d9ef554 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.13 + 0.2.14 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 48b8ec7..9a5f883 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private readonly List _stagedAttachments = []; + // E2E channels whose unlock prompt the user cancelled — don't nag on every reselect. + // Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt. + private readonly HashSet _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase); + private ClientConfig _config; private readonly UserSession _session = new(); @@ -187,7 +191,8 @@ public sealed class AppOrchestrator : IDisposable if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { - if (_conn.RoomKeys.HasKey(channel)) + // Also blocks locked E2E channels (no cached key) — a URL send would be plaintext + if (_conn.RoomKeys.HasKey(channel) || _conn.RoomKeys.IsChannelEncrypted(channel)) { InvokeUI(() => _mainWindow.ShowError( "Sending by URL isn't available in encrypted channels — download the file and /send it instead.")); @@ -290,15 +295,20 @@ public sealed class AppOrchestrator : IDisposable /// private void SendStagedMessage(string channel, string content) { - var staged = _stagedAttachments.ToList(); - _stagedAttachments.Clear(); - InvokeUI(RefreshStagingTray); - - var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); var size = _config.DefaultAsciiSize; RunAsync(async () => { + // Locked E2E channel: block the send and keep the files staged for after the unlock + if (!await EnsureRoomUnlockedForSendAsync(channel)) + return; + + var staged = _stagedAttachments.ToList(); + _stagedAttachments.Clear(); + InvokeUI(RefreshStagingTray); + + var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + var outgoing = new List(); foreach (var path in staged) outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); @@ -417,6 +427,8 @@ public sealed class AppOrchestrator : IDisposable try { crypto = await _conn.Api!.GetChannelCryptoAsync(channelName); + if (crypto is not null) + _conn.RoomKeys.MarkChannelEncrypted(channelName, crypto.IsEncrypted); } catch (Exception ex) { @@ -438,16 +450,20 @@ public sealed class AppOrchestrator : IDisposable { var outcome = await _conn.JoinChannelAsync(channelName, wirePassword); - if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName)) + if (outcome.WrappedRoomKey is not null) { - if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey)) + // A typed passphrase always wins over the cache: unwrap the fresh envelope + // and overwrite any stale key (e.g. the channel was deleted and recreated + // under the same name — the old key would encrypt for nobody). + if (kek is not null && _conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, kek)) { - _conn.RoomKeys.StoreKey(channelName, roomKey); + lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName); // Re-fetch so history decrypts with the now-available room key return await _conn.GetHistoryAsync(channelName); } - return await UnlockRoomKeyAsync(channelName, outcome); + if (!_conn.RoomKeys.HasKey(channelName)) + return await UnlockRoomKeyAsync(channelName, outcome); } return outcome.History; @@ -484,12 +500,17 @@ public sealed class AppOrchestrator : IDisposable var passphrase = await prompt.Task; if (passphrase is null) - return outcome.History; // stays locked; placeholders render instead of content + { + // Stays locked; placeholders render instead of content. Remember the decline + // so reselecting the channel doesn't nag every time. + lock (_declinedUnlocks) _declinedUnlocks.Add(channelName); + return outcome.History; + } var derived = RoomCrypto.DeriveKeys(passphrase, salt); - if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey)) + if (_conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, derived.KeyEncryptionKey)) { - _conn.RoomKeys.StoreKey(channelName, roomKey); + lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName); return await _conn.GetHistoryAsync(channelName); } @@ -497,6 +518,66 @@ public sealed class AppOrchestrator : IDisposable } } + /// + /// True when a channel is end-to-end encrypted, its room key isn't cached, and the + /// user hasn't already declined the unlock prompt this session. + /// + private bool NeedsUnlockPrompt(string channelName) + { + if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName)) + return false; + + lock (_declinedUnlocks) return !_declinedUnlocks.Contains(channelName); + } + + /// + /// Unlock flow for a channel that is already hub-joined (auto-join/reconnect discard + /// the key envelope): rejoin — members pass the gate without a password and the join + /// result carries the envelope — then run the passphrase prompt. On success the + /// decrypted history replaces the locked placeholders. Returns true when unlocked. + /// + private async Task UnlockTrackedChannelAsync(string channelName) + { + try + { + var outcome = await _conn.JoinChannelAsync(channelName, null); + if (outcome.WrappedRoomKey is null) + return false; // not an E2E channel after all + + var history = await UnlockRoomKeyAsync(channelName, outcome); + if (!_conn.RoomKeys.HasKey(channelName)) + return false; // cancelled or never unwrapped + + if (history is not null) + InvokeUI(() => _messageManager.LoadHistory(channelName, history)); + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Unlock flow failed for #{Channel}", channelName); + return false; + } + } + + /// + /// Send guard for end-to-end encrypted channels: without the cached room key nothing + /// may leave the client (it would be plaintext in a room others read as encrypted). + /// Offers the unlock prompt right away — even after an earlier decline, since the user + /// is actively trying to talk here. Returns true when sending is safe. + /// + private async Task EnsureRoomUnlockedForSendAsync(string channelName) + { + if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName)) + return true; + + if (await UnlockTrackedChannelAsync(channelName)) + return true; + + InvokeUI(() => _mainWindow.ShowError( + $"#{channelName} is end-to-end encrypted and locked — nothing was sent. Enter its passphrase to unlock it first.")); + return false; + } + /// /// Changes the current encrypted channel's passphrase: re-derives the join credential /// and re-wraps the cached room content key under the new passphrase. History is @@ -922,6 +1003,7 @@ public sealed class AppOrchestrator : IDisposable _conn.Reconnected += () => { lock (_channelUsersLock) _channelUsers.Clear(); + lock (_declinedUnlocks) _declinedUnlocks.Clear(); RunAsync( async () => await _conn.RejoinChannelsAsync(), "Failed to rejoin channels after reconnect"); @@ -970,6 +1052,7 @@ public sealed class AppOrchestrator : IDisposable } _session.Username = result.Login.Username; + lock (_declinedUnlocks) _declinedUnlocks.Clear(); // Persisted last-read markers for this server — used to seed unread counts, // mention highlights, and "new messages" markers from the fetched histories. @@ -1073,9 +1156,12 @@ public sealed class AppOrchestrator : IDisposable return; } - RunAsync( - async () => await _conn.SendMessageAsync(channelName, content), - "Send failed"); + RunAsync(async () => + { + if (!await EnsureRoomUnlockedForSendAsync(channelName)) + return; + await _conn.SendMessageAsync(channelName, content); + }, "Send failed"); } private void HandleDeleteMessageRequested(Guid messageId) @@ -1112,6 +1198,12 @@ public sealed class AppOrchestrator : IDisposable UpdateServerConfig(server => server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase))); } + else if (NeedsUnlockPrompt(channelName)) + { + // Auto-join/reconnect already hub-joined this E2E channel but discarded the + // key envelope — selecting it is the user's cue to unlock it. + await UnlockTrackedChannelAsync(channelName); + } try { diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 3e25650..6fa2d91 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -38,7 +38,8 @@ public class SavedServer /// /// Cached room content keys for end-to-end encrypted channels on this server, - /// keyed by channel name (base64). Like RefreshToken, these live only on the + /// keyed by channel name and encrypted at rest (see RoomKeyProtector; legacy + /// entries were plain base64). Like RefreshToken, these live only on the /// user's machine — the server never sees them. /// public Dictionary ChannelKeys { get; set; } = []; diff --git a/src/EchoHub.Client/Config/ConfigManager.cs b/src/EchoHub.Client/Config/ConfigManager.cs index 7e30db5..0e300e3 100644 --- a/src/EchoHub.Client/Config/ConfigManager.cs +++ b/src/EchoHub.Client/Config/ConfigManager.cs @@ -9,6 +9,9 @@ public static class ConfigManager private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json"); + /// Directory holding the client config and local key material. + public static string ConfigDirectory => ConfigDir; + // Load-mutate-save cycles run from both the UI thread and background tasks // (token refresh, room keys, last-read checkpoints) — serialize file access. private static readonly Lock FileLock = new(); diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index a4ab665..8e4bd85 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -12,6 +12,7 @@ + diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index dfc5a29..ac3301a 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -112,6 +112,11 @@ internal sealed class ConnectionManager : IAsyncDisposable var channels = await _apiClient.GetChannelsAsync(); + // Known E2E channels — senders consult this so a client without the room + // key never emits plaintext into an encrypted room + foreach (var channel in channels) + _roomKeys.MarkChannelEncrypted(channel.Name, channel.IsEncrypted); + // Join default channel + fetch history onStatus("Joining channels..."); _joinedChannels.Clear(); @@ -296,7 +301,11 @@ internal sealed class ConnectionManager : IAsyncDisposable connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason); connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id); connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch); - connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch); + connection.OnChannelUpdated += ch => + { + _roomKeys.MarkChannelEncrypted(ch.Name, ch.IsEncrypted); + ChannelUpdated?.Invoke(ch); + }; connection.OnError += msg => Error?.Invoke(msg); connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status); connection.OnReconnected += () => Reconnected?.Invoke(); diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 0fa0034..70a5e67 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -26,6 +26,21 @@ public sealed class ChannelPasswordRequiredException : Exception } } +/// +/// Thrown when sending into an end-to-end encrypted channel whose room key isn't cached: +/// without the key the message would leave the client as plaintext, which must never happen. +/// +public sealed class RoomLockedException : Exception +{ + public string ChannelName { get; } + + public RoomLockedException(string channelName) + : base($"#{channelName} is end-to-end encrypted and locked — enter its passphrase to unlock it before sending.") + { + ChannelName = channelName; + } +} + public sealed class EchoHubConnection : IAsyncDisposable { public const string LockedMessagePlaceholder = @@ -167,6 +182,8 @@ public sealed class EchoHubConnection : IAsyncDisposable throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected."); throw new InvalidOperationException(result.Error ?? "Failed to join channel."); } + if (result.WrappedRoomKey is not null) + _roomKeys.MarkChannelEncrypted(channelName, true); return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey); } @@ -180,6 +197,8 @@ public sealed class EchoHubConnection : IAsyncDisposable // Room layer first (end-to-end, server can't read), then transport encryption if (_roomKeys.TryGetKey(channelName, out var roomKey)) content = RoomCrypto.EncryptText(content, roomKey); + else if (_roomKeys.IsChannelEncrypted(channelName)) + throw new RoomLockedException(channelName); // never fall through to plaintext var encrypted = _encryption.Encrypt(content); await _connection.InvokeAsync("SendMessage", channelName, encrypted); diff --git a/src/EchoHub.Client/Services/RoomKeyProtector.cs b/src/EchoHub.Client/Services/RoomKeyProtector.cs new file mode 100644 index 0000000..51ba8e7 --- /dev/null +++ b/src/EchoHub.Client/Services/RoomKeyProtector.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using EchoHub.Core.Security; +using Serilog; + +namespace EchoHub.Client.Services; + +/// +/// Encrypts cached room content keys at rest so the client config never holds them as +/// plain base64. Windows uses DPAPI (current-user scope, format prefix "dp1:"). On other +/// platforms the keys are AES-GCM encrypted with a per-user master key file stored next +/// to the config with 0600 permissions (prefix "k1:") — without an OS keychain that is +/// file-permission-level protection, not zero-knowledge: anyone who can read both the +/// config and the key file can recover the room keys. Values with no recognized prefix +/// are legacy plain-base64 keys from older clients; they load once and are re-encrypted. +/// The room passphrase itself is never stored in any form. +/// +public sealed class RoomKeyProtector +{ + public const string DpapiPrefix = "dp1:"; + public const string KeyFilePrefix = "k1:"; + + private const string KeyFileName = "roomkeys.key"; + private const int MasterKeySizeBytes = 32; + private const int RoomKeySizeBytes = 32; + + private readonly string _keyFilePath; + private readonly bool _useDpapi; + private readonly Lock _lock = new(); + private byte[]? _masterKey; + + /// Directory holding the master key file (the client config dir). + /// Overrides the platform default (DPAPI on Windows) — for tests. + public RoomKeyProtector(string keyDirectory, bool? useDpapi = null) + { + _keyFilePath = Path.Combine(keyDirectory, KeyFileName); + _useDpapi = useDpapi ?? OperatingSystem.IsWindows(); + } + + /// Encrypts a room key for storage in the config file. + public string Protect(byte[] roomKey) + { + if (_useDpapi && OperatingSystem.IsWindows()) + return DpapiPrefix + Convert.ToBase64String( + ProtectedData.Protect(roomKey, null, DataProtectionScope.CurrentUser)); + + return KeyFilePrefix + Convert.ToBase64String(RoomCrypto.EncryptBytes(roomKey, GetMasterKey())); + } + + /// + /// Decrypts a stored value back into a room key. is true when + /// the value was an unencrypted legacy entry that should be re-persisted via + /// . Returns false for unreadable values (wrong user/machine, missing + /// or regenerated key file, malformed data) — the caller drops the entry and the user can + /// recover it by re-entering the passphrase. + /// + public bool TryUnprotect(string stored, out byte[] roomKey, out bool wasLegacy) + { + roomKey = []; + wasLegacy = false; + + try + { + if (stored.StartsWith(DpapiPrefix, StringComparison.Ordinal)) + { + if (!OperatingSystem.IsWindows()) + return false; // config copied from a Windows machine + + roomKey = ProtectedData.Unprotect( + Convert.FromBase64String(stored[DpapiPrefix.Length..]), null, DataProtectionScope.CurrentUser); + return roomKey.Length > 0; + } + + if (stored.StartsWith(KeyFilePrefix, StringComparison.Ordinal)) + { + if (!File.Exists(_keyFilePath)) + return false; + + roomKey = RoomCrypto.DecryptBytes( + Convert.FromBase64String(stored[KeyFilePrefix.Length..]), GetMasterKey()); + return roomKey.Length > 0; + } + + // No recognized prefix — legacy plain-base64 room key from a pre-encryption client + roomKey = Convert.FromBase64String(stored); + wasLegacy = true; + return roomKey.Length == RoomKeySizeBytes; + } + catch (Exception ex) when (ex is FormatException or CryptographicException + or IOException or UnauthorizedAccessException) + { + return false; + } + } + + private byte[] GetMasterKey() + { + lock (_lock) + { + if (_masterKey is not null) + return _masterKey; + + if (File.Exists(_keyFilePath)) + { + var existing = File.ReadAllBytes(_keyFilePath); + if (existing.Length == MasterKeySizeBytes) + return _masterKey = existing; + + Log.Warning("Room-key master key file has unexpected size — regenerating (previously cached keys become unreadable)"); + } + + var key = RandomNumberGenerator.GetBytes(MasterKeySizeBytes); + Directory.CreateDirectory(Path.GetDirectoryName(_keyFilePath)!); + File.WriteAllBytes(_keyFilePath, key); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(_keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + return _masterKey = key; + } + } +} diff --git a/src/EchoHub.Client/Services/RoomKeyStore.cs b/src/EchoHub.Client/Services/RoomKeyStore.cs index b274bcc..fe8b7fd 100644 --- a/src/EchoHub.Client/Services/RoomKeyStore.cs +++ b/src/EchoHub.Client/Services/RoomKeyStore.cs @@ -1,4 +1,5 @@ using EchoHub.Client.Config; +using EchoHub.Core.Security; using Serilog; namespace EchoHub.Client.Services; @@ -6,14 +7,28 @@ namespace EchoHub.Client.Services; /// /// Holds room content keys for end-to-end encrypted channels: in-memory for the /// active session, persisted per-server in the client config (like saved sessions) -/// so users don't retype the passphrase every launch. Keys never leave this machine. +/// so users don't retype the passphrase every launch. Keys never leave this machine +/// and are encrypted at rest by . Also tracks which +/// channels are known to be end-to-end encrypted, so senders can refuse to emit +/// plaintext into a room whose key isn't cached yet. /// public sealed class RoomKeyStore { private readonly Dictionary _keys = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _encryptedChannels = new(StringComparer.OrdinalIgnoreCase); + private readonly RoomKeyProtector _protector; private readonly Lock _lock = new(); private string? _serverUrl; + public RoomKeyStore() : this(new RoomKeyProtector(ConfigManager.ConfigDirectory)) + { + } + + public RoomKeyStore(RoomKeyProtector protector) + { + _protector = protector; + } + /// Binds the store to a server and loads that server's cached keys from config. public void LoadForServer(string serverUrl) { @@ -21,21 +36,34 @@ public sealed class RoomKeyStore { _serverUrl = serverUrl; _keys.Clear(); + _encryptedChannels.Clear(); var server = FindServer(ConfigManager.Load(), serverUrl); if (server is null) return; - foreach (var (channel, base64) in server.ChannelKeys) + var legacyFound = false; + foreach (var (channel, stored) in server.ChannelKeys) { - try + if (_protector.TryUnprotect(stored, out var key, out var wasLegacy)) { - _keys[channel] = Convert.FromBase64String(base64); + _keys[channel] = key; + legacyFound |= wasLegacy; } - catch (FormatException) + else { - Log.Warning("Ignoring malformed cached room key for #{Channel}", channel); + Log.Warning("Ignoring unreadable cached room key for #{Channel}", channel); } } + + // One-way upgrade: legacy plain-base64 entries get re-persisted encrypted + // (unreadable entries drop out — the unlock prompt recovers those rooms). + if (legacyFound) + Persist(s => + { + s.ChannelKeys.Clear(); + foreach (var (channel, key) in _keys) + s.ChannelKeys[channel] = _protector.Protect(key); + }); } } @@ -62,10 +90,26 @@ public sealed class RoomKeyStore lock (_lock) { _keys[channelName] = key; - Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key)); + _encryptedChannels.Add(channelName); + Persist(server => server.ChannelKeys[channelName] = _protector.Protect(key)); } } + /// + /// Unwraps a fresh key envelope and caches the key, overwriting any stale cached key + /// (e.g. the channel was deleted and recreated under the same name, so the old key + /// would encrypt messages nobody else can read). Returns false when the KEK doesn't + /// open the envelope — the cache is left untouched. + /// + public bool TryStoreFromEnvelope(string channelName, string wrappedRoomKey, byte[] kek) + { + if (!RoomCrypto.TryUnwrapRoomKey(wrappedRoomKey, kek, out var roomKey)) + return false; + + StoreKey(channelName, roomKey); + return true; + } + public void RemoveKey(string channelName) { lock (_lock) @@ -75,11 +119,36 @@ public sealed class RoomKeyStore } } + /// + /// Records whether a channel is end-to-end encrypted (from channel listings, crypto + /// metadata, or join outcomes). Senders consult this to block plaintext into rooms + /// whose key isn't cached. + /// + public void MarkChannelEncrypted(string channelName, bool isEncrypted) + { + lock (_lock) + { + if (isEncrypted) + _encryptedChannels.Add(channelName); + else + _encryptedChannels.Remove(channelName); + } + } + + public bool IsChannelEncrypted(string channelName) + { + lock (_lock) + { + return _encryptedChannels.Contains(channelName); + } + } + public void Clear() { lock (_lock) { _keys.Clear(); + _encryptedChannels.Clear(); _serverUrl = null; } } diff --git a/src/EchoHub.Tests/RoomKeyProtectionTests.cs b/src/EchoHub.Tests/RoomKeyProtectionTests.cs new file mode 100644 index 0000000..2825fd5 --- /dev/null +++ b/src/EchoHub.Tests/RoomKeyProtectionTests.cs @@ -0,0 +1,168 @@ +using System.Security.Cryptography; +using EchoHub.Client.Services; +using EchoHub.Core.Security; +using Xunit; + +namespace EchoHub.Tests; + +/// +/// At-rest encryption of the cached room keys (RoomKeyProtector) and the store-level +/// decisions built on it (RoomKeyStore): fresh envelopes overwrite stale cached keys, +/// legacy plain-base64 entries are recognized for the one-way migration. +/// +public class RoomKeyProtectorTests : IDisposable +{ + private readonly string _dir = Directory.CreateTempSubdirectory("echohub-keyprotector-").FullName; + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort cleanup */ } + } + + [Fact] + public void KeyFile_Protect_RoundTrips() + { + var protector = new RoomKeyProtector(_dir, useDpapi: false); + var key = RoomCrypto.GenerateRoomKey(); + + var stored = protector.Protect(key); + + Assert.StartsWith(RoomKeyProtector.KeyFilePrefix, stored); + Assert.True(protector.TryUnprotect(stored, out var recovered, out var wasLegacy)); + Assert.Equal(key, recovered); + Assert.False(wasLegacy); + } + + [Fact] + public void Dpapi_Protect_RoundTrips() + { + if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only + + var protector = new RoomKeyProtector(_dir, useDpapi: true); + var key = RoomCrypto.GenerateRoomKey(); + + var stored = protector.Protect(key); + + Assert.StartsWith(RoomKeyProtector.DpapiPrefix, stored); + Assert.True(protector.TryUnprotect(stored, out var recovered, out var wasLegacy)); + Assert.Equal(key, recovered); + Assert.False(wasLegacy); + } + + [Fact] + public void Protect_DoesNotStoreThePlainKey() + { + var protector = new RoomKeyProtector(_dir, useDpapi: false); + var key = RoomCrypto.GenerateRoomKey(); + + var stored = protector.Protect(key); + + Assert.DoesNotContain(Convert.ToBase64String(key), stored); + } + + [Fact] + public void Legacy_PlainBase64_IsAccepted_AndFlaggedForMigration() + { + var protector = new RoomKeyProtector(_dir, useDpapi: false); + var key = RoomCrypto.GenerateRoomKey(); + + Assert.True(protector.TryUnprotect(Convert.ToBase64String(key), out var recovered, out var wasLegacy)); + Assert.Equal(key, recovered); + Assert.True(wasLegacy); + + // The migration re-protects it; the upgraded value round-trips and is no longer legacy + var upgraded = protector.Protect(recovered); + Assert.True(protector.TryUnprotect(upgraded, out var recoveredAgain, out var stillLegacy)); + Assert.Equal(key, recoveredAgain); + Assert.False(stillLegacy); + } + + [Fact] + public void Malformed_Values_AreRejected() + { + var protector = new RoomKeyProtector(_dir, useDpapi: false); + + Assert.False(protector.TryUnprotect("not base64 at all!!", out _, out _)); + Assert.False(protector.TryUnprotect(RoomKeyProtector.KeyFilePrefix + "not base64!!", out _, out _)); + // Valid base64 but not a 32-byte room key → not a usable legacy entry + Assert.False(protector.TryUnprotect(Convert.ToBase64String([1, 2, 3]), out _, out _)); + } + + [Fact] + public void KeyFile_Lost_MakesStoredValuesUnreadable_NotThrow() + { + var protector = new RoomKeyProtector(_dir, useDpapi: false); + var stored = protector.Protect(RoomCrypto.GenerateRoomKey()); + + File.Delete(Path.Combine(_dir, "roomkeys.key")); + + // A fresh protector regenerates a different master key — the value must fail + // cleanly (entry dropped, passphrase prompt recovers it), not throw + var fresh = new RoomKeyProtector(_dir, useDpapi: false); + Assert.False(fresh.TryUnprotect(stored, out _, out _)); + } +} + +public class RoomKeyStoreEnvelopeTests +{ + // Note: without LoadForServer the store never touches the config file on disk — + // these tests exercise the in-memory decision logic only. + + private static RoomKeyStore NewStore() => + new(new RoomKeyProtector(Path.Combine(Path.GetTempPath(), "echohub-unused"), useDpapi: false)); + + [Fact] + public void TryStoreFromEnvelope_FreshEnvelope_OverwritesStaleCachedKey() + { + var store = NewStore(); + var stale = RoomCrypto.GenerateRoomKey(); + store.StoreKey("vault", stale); + + // Channel deleted and recreated under the same name → new room key, new envelope + var fresh = RoomCrypto.GenerateRoomKey(); + var kek = RandomNumberGenerator.GetBytes(32); + var wrapped = RoomCrypto.WrapRoomKey(fresh, kek); + + Assert.True(store.TryStoreFromEnvelope("vault", wrapped, kek)); + Assert.True(store.TryGetKey("vault", out var current)); + Assert.Equal(fresh, current); + } + + [Fact] + public void TryStoreFromEnvelope_WrongKek_KeepsCachedKey() + { + var store = NewStore(); + var cached = RoomCrypto.GenerateRoomKey(); + store.StoreKey("vault", cached); + + var wrapped = RoomCrypto.WrapRoomKey(RoomCrypto.GenerateRoomKey(), RandomNumberGenerator.GetBytes(32)); + + Assert.False(store.TryStoreFromEnvelope("vault", wrapped, RandomNumberGenerator.GetBytes(32))); + Assert.True(store.TryGetKey("vault", out var current)); + Assert.Equal(cached, current); + } + + [Fact] + public void MarkChannelEncrypted_TracksAndUntracks() + { + var store = NewStore(); + + Assert.False(store.IsChannelEncrypted("vault")); + + store.MarkChannelEncrypted("vault", true); + Assert.True(store.IsChannelEncrypted("vault")); + Assert.True(store.IsChannelEncrypted("VAULT")); // channel names are case-insensitive + + store.MarkChannelEncrypted("vault", false); + Assert.False(store.IsChannelEncrypted("vault")); + } + + [Fact] + public void StoreKey_MarksChannelEncrypted() + { + var store = NewStore(); + store.StoreKey("vault", RoomCrypto.GenerateRoomKey()); + + Assert.True(store.IsChannelEncrypted("vault")); + } +} From 4f2cffa37230fd88ae66c26a555966c1826e80e4 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 19:33:44 +0200 Subject: [PATCH 3/7] feat: add clipboard image handling for pasting and staging attachments --- src/EchoHub.Client/AppOrchestrator.cs | 112 +++++++- src/EchoHub.Client/Services/ClipboardImage.cs | 257 ++++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 31 ++- src/EchoHub.Tests/ClipboardImageTests.cs | 78 ++++++ 4 files changed, 462 insertions(+), 16 deletions(-) create mode 100644 src/EchoHub.Client/Services/ClipboardImage.cs create mode 100644 src/EchoHub.Tests/ClipboardImageTests.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 9a5f883..8c73134 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private readonly List _stagedAttachments = []; + // Temp PNGs created for clipboard-image pastes; deleted once their message is sent + // (or the staging tray is cleared) so pasted screenshots don't pile up in %TEMP%. + private readonly HashSet _tempPastedFiles = []; + // E2E channels whose unlock prompt the user cancelled — don't nag on every reselect. // Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt. private readonly HashSet _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase); @@ -94,6 +98,8 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnDisconnectRequested += HandleDisconnect; _mainWindow.OnLogoutRequested += HandleLogout; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; + _mainWindow.OnFilesStaged += HandleFilesStaged; + _mainWindow.OnImagePasted += HandleImagePasted; _mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnProfileRequested += HandleProfileRequested; _mainWindow.OnStatusRequested += HandleStatusRequested; @@ -221,11 +227,89 @@ public sealed class AppOrchestrator : IDisposable private Task HandleCmdClearAttachments() { + var temps = _stagedAttachments.Where(_tempPastedFiles.Contains).ToList(); + _tempPastedFiles.ExceptWith(temps); + CleanupPastedTempFiles(temps); + _stagedAttachments.Clear(); InvokeUI(RefreshStagingTray); return Task.CompletedTask; } + /// + /// Stages a batch of local files (multi-file paste or drag-and-drop) as attachments of the + /// next message. Runs synchronously on the UI thread — unlike routing each file through a + /// fire-and-forget /send command, a 10-file paste can't race the staging list. + /// + private void HandleFilesStaged(string channel, IReadOnlyList files) + { + if (!_conn.IsAuthenticated || !_conn.IsConnected) + return; + + var slotsLeft = HubConstants.MaxAttachmentsPerMessage - _stagedAttachments.Count; + _stagedAttachments.AddRange(files.Take(Math.Max(0, slotsLeft))); + + if (files.Count > slotsLeft) + _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."); + + RefreshStagingTray(); + } + + /// + /// Stages an image pasted as raw clipboard data (copied from a browser, a screenshot tool, + /// or an image editor). The PNG is written to a per-paste temp folder so it flows through + /// the same path-based staging/encryption pipeline as regular files, and the temp file is + /// deleted once the message is sent. + /// + private void HandleImagePasted(string channel, byte[] png) + { + if (!_conn.IsAuthenticated || !_conn.IsConnected) + return; + + if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage) + { + _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."); + return; + } + + try + { + // A unique folder per paste keeps the Discord-style "image.png" display name + // while letting several pasted images coexist in one message. + var dir = Path.Combine(Path.GetTempPath(), "EchoHub", "pasted", Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "image.png"); + File.WriteAllBytes(path, png); + + _tempPastedFiles.Add(path); + _stagedAttachments.Add(path); + RefreshStagingTray(); + } + catch (Exception ex) + { + Log.Error(ex, "Staging a pasted clipboard image failed"); + _mainWindow.ShowError($"Pasting image failed: {ex.Message}"); + } + } + + /// Best-effort removal of pasted-image temp files and their per-paste folders. + private static void CleanupPastedTempFiles(IReadOnlyList files) + { + foreach (var file in files) + { + try + { + File.Delete(file); + if (Path.GetDirectoryName(file) is { } dir) + Directory.Delete(dir); + } + catch (Exception ex) + { + Log.Debug(ex, "Could not delete pasted-image temp file {File}", file); + } + } + } + /// /// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or /// small/medium/large). The choice is a persistent preference applied to attached images. @@ -307,17 +391,29 @@ public sealed class AppOrchestrator : IDisposable _stagedAttachments.Clear(); InvokeUI(RefreshStagingTray); - var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + // Pasted clipboard images live in temp files; once this send owns them they are + // deleted whether the upload succeeds or fails (the tray is already cleared). + var tempFiles = staged.Where(_tempPastedFiles.Contains).ToList(); + _tempPastedFiles.ExceptWith(tempFiles); - var outgoing = new List(); - foreach (var path in staged) - outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); + try + { + var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); - var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) - ? RoomCrypto.EncryptText(content, roomKey) - : content; + var outgoing = new List(); + foreach (var path in staged) + outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); - await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); + var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) + ? RoomCrypto.EncryptText(content, roomKey) + : content; + + await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); + } + finally + { + CleanupPastedTempFiles(tempFiles); + } }, "Send failed"); } diff --git a/src/EchoHub.Client/Services/ClipboardImage.cs b/src/EchoHub.Client/Services/ClipboardImage.cs new file mode 100644 index 0000000..814e463 --- /dev/null +++ b/src/EchoHub.Client/Services/ClipboardImage.cs @@ -0,0 +1,257 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Serilog; +using SixLabors.ImageSharp; + +namespace EchoHub.Client.Services; + +/// +/// Reads raw image data from the OS clipboard (e.g. an image copied from a browser, or a +/// Win+Shift+S screenshot), which terminals cannot paste as text. Always returns PNG bytes: +/// clipboard PNG data is passed through, clipboard bitmaps (CF_DIB) are re-encoded. +/// +public static class ClipboardImage +{ + private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47]; + + public static bool TryGetPng(out byte[] png) + { + png = []; + try + { + if (OperatingSystem.IsWindows()) + return TryGetWindows(out png); + if (OperatingSystem.IsLinux()) + return TryGetLinux(out png); + if (OperatingSystem.IsMacOS()) + return TryGetMacOS(out png); + } + catch (Exception ex) + { + Log.Warning(ex, "Reading an image from the clipboard failed"); + } + + return false; + } + + private static bool IsPng(byte[] data) => + data.Length > PngMagic.Length && data.AsSpan(0, PngMagic.Length).SequenceEqual(PngMagic); + + /// + /// Converts clipboard DIB bytes (a BITMAPINFOHEADER/V4/V5 + optional palette/masks + pixel + /// data, i.e. a .bmp file without its 14-byte file header) to PNG. Returns null when the + /// data is malformed or not decodable as a bitmap. + /// + public static byte[]? DibToPng(byte[] dib) + { + if (dib.Length < 40) + return null; + + var headerSize = BitConverter.ToInt32(dib, 0); + if (headerSize < 40 || headerSize > dib.Length) + return null; + + var bitCount = BitConverter.ToUInt16(dib, 14); + var compression = BitConverter.ToUInt32(dib, 16); + var clrUsed = BitConverter.ToUInt32(dib, 32); + + // Pixel data offset: file header + info header + color masks + palette. + // BI_BITFIELDS masks follow a plain 40-byte header; larger headers embed them. + var maskBytes = headerSize == 40 && compression == 3 ? 12 + : headerSize == 40 && compression == 6 ? 16 + : 0; + var paletteEntries = clrUsed != 0 ? clrUsed + : bitCount <= 8 ? 1u << bitCount + : 0u; + var pixelOffset = (uint)(14 + headerSize + maskBytes) + paletteEntries * 4; + + var bmp = new byte[14 + dib.Length]; + bmp[0] = (byte)'B'; + bmp[1] = (byte)'M'; + BitConverter.TryWriteBytes(bmp.AsSpan(2), (uint)bmp.Length); + BitConverter.TryWriteBytes(bmp.AsSpan(10), pixelOffset); + dib.CopyTo(bmp, 14); + + try + { + using var image = Image.Load(bmp); + using var ms = new MemoryStream(); + image.SaveAsPng(ms); + return ms.ToArray(); + } + catch (Exception ex) when (ex is ImageFormatException or InvalidOperationException) + { + Log.Warning(ex, "Clipboard DIB could not be decoded as a bitmap"); + return null; + } + } + + // ── Windows: "PNG" / "image/png" registered formats, then CF_DIB ───────── + + private const uint CfDib = 8; + + [SupportedOSPlatform("windows")] + private static bool TryGetWindows(out byte[] png) + { + png = []; + + // Browsers register a "PNG" (Chromium) or "image/png" (some apps) clipboard format + // preserving transparency; CF_DIB is synthesized by Windows for everything else + // (screenshots, image editors), so together these cover all image sources. + var pngFormat = RegisterClipboardFormatW("PNG"); + var mimeFormat = RegisterClipboardFormatW("image/png"); + + var hasAny = (pngFormat != 0 && IsClipboardFormatAvailable(pngFormat)) + || (mimeFormat != 0 && IsClipboardFormatAvailable(mimeFormat)) + || IsClipboardFormatAvailable(CfDib); + if (!hasAny) + return false; + + var opened = false; + for (var attempt = 0; attempt < 5 && !opened; attempt++) + opened = OpenClipboard(IntPtr.Zero); + if (!opened) + return false; + + try + { + foreach (var format in new[] { pngFormat, mimeFormat }) + { + if (format == 0 || !IsClipboardFormatAvailable(format)) + continue; + var data = ReadHGlobal(GetClipboardData(format)); + if (data is not null && IsPng(data)) + { + png = data; + return true; + } + } + + if (IsClipboardFormatAvailable(CfDib) + && ReadHGlobal(GetClipboardData(CfDib)) is { } dib + && DibToPng(dib) is { } converted) + { + png = converted; + return true; + } + + return false; + } + finally + { + CloseClipboard(); + } + } + + [SupportedOSPlatform("windows")] + private static byte[]? ReadHGlobal(IntPtr handle) + { + if (handle == IntPtr.Zero) + return null; + + var ptr = GlobalLock(handle); + if (ptr == IntPtr.Zero) + return null; + + try + { + var size = (int)GlobalSize(handle); + if (size <= 0) + return null; + var data = new byte[size]; + Marshal.Copy(ptr, data, 0, size); + return data; + } + finally + { + GlobalUnlock(handle); + } + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenClipboard(IntPtr hWndNewOwner); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseClipboard(); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsClipboardFormatAvailable(uint format); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr GetClipboardData(uint uFormat); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint RegisterClipboardFormatW(string lpszFormat); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalLock(IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalUnlock(IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nuint GlobalSize(IntPtr hMem); + + // ── Linux: image/png via wl-paste or xclip ──────────────────────────────── + + [SupportedOSPlatform("linux")] + private static bool TryGetLinux(out byte[] png) + { + png = []; + var data = RunForBytes("wl-paste", ["--type", "image/png"]) + ?? RunForBytes("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]); + if (data is null || !IsPng(data)) + return false; + + png = data; + return true; + } + + // ── macOS: pngpaste (brew install pngpaste), when present ──────────────── + + [SupportedOSPlatform("macos")] + private static bool TryGetMacOS(out byte[] png) + { + png = []; + var data = RunForBytes("pngpaste", ["-"]); + if (data is null || !IsPng(data)) + return false; + + png = data; + return true; + } + + private static byte[]? RunForBytes(string fileName, IEnumerable args) + { + var psi = new ProcessStartInfo(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + try + { + using var process = Process.Start(psi); + if (process is null) + return null; + + using var ms = new MemoryStream(); + process.StandardOutput.BaseStream.CopyTo(ms); + process.WaitForExit(2000); + return process.ExitCode == 0 && ms.Length > 0 ? ms.ToArray() : null; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + { + return null; // tool not installed + } + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 94919e6..9ff8945 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -87,6 +87,18 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnMessageSubmitted; + /// + /// Fired when local files arrive via paste or drag-and-drop to be staged as attachments. + /// Parameters: channel name, absolute paths of existing files. + /// + public event Action>? OnFilesStaged; + + /// + /// Fired when raw image data is pasted from the clipboard (e.g. copied from a browser or a + /// screenshot tool). Parameters: channel name, PNG-encoded image bytes. + /// + public event Action? OnImagePasted; + /// /// Fired when the user requests to connect via the menu. /// @@ -744,11 +756,15 @@ public sealed partial class MainWindow : Runnable } else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) { - // If a file was copied in the OS file manager, the clipboard holds a file list - // (not text) — attach it. Otherwise paste text. This is the reliable path on - // Windows Terminal, which never pastes copied files as text. + // Discord-style paste priority. Copied files in the OS file manager put a file + // list (not text) on the clipboard — attach them all. Copied image data (browser + // right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text. + // Terminals never deliver either of the first two as text, so this is the only path. if (ClipboardFiles.TryGetFiles(out var pastedFiles)) StageFiles(pastedFiles); + else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel) + && ClipboardImage.TryGetPng(out var pastedPng)) + OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng); else GuardedClipboardAction(() => _inputField.Paste(), "paste"); e.Handled = true; @@ -872,17 +888,16 @@ public sealed partial class MainWindow : Runnable } /// - /// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which - /// stages them; the next Enter sends them with any typed caption. + /// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the + /// next Enter sends them with any typed caption. /// - private void StageFiles(IEnumerable files) + private void StageFiles(IReadOnlyList files) { var channel = _messageManager.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; - foreach (var file in files) - OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\""); + OnFilesStaged?.Invoke(channel, files); } private void OnChatViewportChanged() diff --git a/src/EchoHub.Tests/ClipboardImageTests.cs b/src/EchoHub.Tests/ClipboardImageTests.cs new file mode 100644 index 0000000..0612b5c --- /dev/null +++ b/src/EchoHub.Tests/ClipboardImageTests.cs @@ -0,0 +1,78 @@ +using EchoHub.Client.Services; +using EchoHub.Core.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +namespace EchoHub.Tests; + +public class ClipboardImageTests +{ + /// + /// Encodes an image as BMP and strips the 14-byte BITMAPFILEHEADER, producing exactly what + /// the Windows clipboard hands out as CF_DIB. + /// + private static byte[] MakeDib(Image image, BmpBitsPerPixel bpp) + { + using var ms = new MemoryStream(); + image.Save(ms, new BmpEncoder { BitsPerPixel = bpp }); + return ms.ToArray()[14..]; + } + + private static Image MakeTestImage() + { + var image = new Image(4, 3); + image[0, 0] = new Rgba32(255, 0, 0); + image[3, 2] = new Rgba32(0, 0, 255); + return image; + } + + [Theory] + [InlineData(BmpBitsPerPixel.Pixel24)] + [InlineData(BmpBitsPerPixel.Pixel32)] + [InlineData(BmpBitsPerPixel.Pixel8)] // palette-based: exercises the palette offset math + public void DibToPng_ConvertsDibToValidPng(BmpBitsPerPixel bpp) + { + using var original = MakeTestImage(); + var dib = MakeDib(original, bpp); + + var png = ClipboardImage.DibToPng(dib); + + Assert.NotNull(png); + using var pngStream = new MemoryStream(png); + Assert.True(FileValidationHelper.IsValidImage(pngStream)); + + using var decoded = Image.Load(png); + Assert.Equal(original.Width, decoded.Width); + Assert.Equal(original.Height, decoded.Height); + } + + [Fact] + public void DibToPng_PreservesPixels_For24Bpp() + { + using var original = MakeTestImage(); + var dib = MakeDib(original, BmpBitsPerPixel.Pixel24); + + var png = ClipboardImage.DibToPng(dib); + + Assert.NotNull(png); + using var decoded = Image.Load(png); + Assert.Equal(new Rgba32(255, 0, 0), decoded[0, 0]); + Assert.Equal(new Rgba32(0, 0, 255), decoded[3, 2]); + } + + [Fact] + public void DibToPng_ReturnsNull_ForTruncatedData() + { + Assert.Null(ClipboardImage.DibToPng([0x28, 0x00, 0x00])); + } + + [Fact] + public void DibToPng_ReturnsNull_ForGarbageData() + { + var garbage = new byte[256]; + new Random(42).NextBytes(garbage); + Assert.Null(ClipboardImage.DibToPng(garbage)); + } +} From 2157884e6122b9b5c08c4ad2618e989d37c5419d Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 19:39:28 +0200 Subject: [PATCH 4/7] refactor: replace Key constants with KeyCode for improved clarity and performance --- src/EchoHub.Client/UI/MainWindow.cs | 181 ++++++++++++++-------------- 1 file changed, 93 insertions(+), 88 deletions(-) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 9ff8945..e518cc7 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -11,6 +11,7 @@ using Serilog; using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; +using Terminal.Gui.Drivers; using Terminal.Gui.Input; using Terminal.Gui.Text; using Terminal.Gui.ViewBase; @@ -41,23 +42,24 @@ public sealed partial class MainWindow : Runnable private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message"; - private static readonly Key F2Key = Key.F2; + private const KeyCode F2Key = KeyCode.F2; private bool _hasStagedAttachments; internal static readonly string AppVersion = typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?"; - // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) - private static readonly Key EnterKey = Key.Enter; - private static readonly Key NewlineKey = Key.N.WithCtrl; - private static readonly Key AltQKey = Key.Q.WithAlt; - private static readonly Key TabKey = Key.Tab; - private static readonly Key CtrlKKey = Key.K.WithCtrl; - private static readonly Key CtrlVKey = Key.V.WithCtrl; - private static readonly Key CtrlXKey = Key.X.WithCtrl; - private static readonly Key CtrlCKey = Key.C.WithCtrl; - private static readonly Key CtrlYKey = Key.Y.WithCtrl; - private static readonly Key F6Key = Key.F6; + // Key bindings as KeyCode constants: comparing raw KeyCodes avoids Key.Equals (which also + // checks Handled), and constants make them usable as switch case labels. + private const KeyCode EnterKey = KeyCode.Enter; + private const KeyCode NewlineKey = KeyCode.N | KeyCode.CtrlMask; + private const KeyCode AltQKey = KeyCode.Q | KeyCode.AltMask; + private const KeyCode TabKey = KeyCode.Tab; + private const KeyCode CtrlKKey = KeyCode.K | KeyCode.CtrlMask; + private const KeyCode CtrlVKey = KeyCode.V | KeyCode.CtrlMask; + private const KeyCode CtrlXKey = KeyCode.X | KeyCode.CtrlMask; + private const KeyCode CtrlCKey = KeyCode.C | KeyCode.CtrlMask; + private const KeyCode CtrlYKey = KeyCode.Y | KeyCode.CtrlMask; + private const KeyCode F6Key = KeyCode.F6; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = @@ -569,7 +571,7 @@ public sealed partial class MainWindow : Runnable private void OnMessageListKeyDown(object? sender, Key e) { // F6 returns focus to the input box. - if (e.KeyCode == F6Key.KeyCode) + if (e.KeyCode == F6Key) { _inputField.SetFocus(); e.Handled = true; @@ -715,70 +717,69 @@ public sealed partial class MainWindow : Runnable private void OnInputKeyDown(object? sender, Key e) { - if (e.KeyCode == TabKey.KeyCode) + switch (e.KeyCode) { - TryAutocompleteCommand(); - e.Handled = true; - } - else if (e.KeyCode == NewlineKey.KeyCode) - { - _inputField.InsertText("\n"); - e.Handled = true; - } - else if (e.KeyCode == EnterKey.KeyCode) - { - var text = _inputField.Text?.Trim() ?? string.Empty; - // Send when there's text, or when only attachments are staged (empty caption). - if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments) - && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) - { - OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text); - _inputField.Text = string.Empty; - } - e.Handled = true; - } - else if (e.KeyCode == AltQKey.KeyCode) - { - _app.RequestStop(); - e.Handled = true; - } - else if (e.KeyCode == CtrlKKey.KeyCode) - { - ShowSearchDialog(); - e.Handled = true; - } - else if (e.KeyCode == F6Key.KeyCode) - { - // Move focus into the message list so you can select a message (arrows) and - // delete it (Delete). F6 again returns focus here. (Esc is the app quit key.) - FocusMessageList(); - e.Handled = true; - } - else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) - { - // Discord-style paste priority. Copied files in the OS file manager put a file - // list (not text) on the clipboard — attach them all. Copied image data (browser - // right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text. - // Terminals never deliver either of the first two as text, so this is the only path. - if (ClipboardFiles.TryGetFiles(out var pastedFiles)) - StageFiles(pastedFiles); - else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel) - && ClipboardImage.TryGetPng(out var pastedPng)) - OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng); - else - GuardedClipboardAction(() => _inputField.Paste(), "paste"); - e.Handled = true; - } - else if (e.KeyCode == CtrlXKey.KeyCode) - { - GuardedClipboardAction(() => _inputField.Cut(), "cut"); - e.Handled = true; - } - else if (e.KeyCode == CtrlCKey.KeyCode) - { - GuardedClipboardAction(() => _inputField.Copy(), "copy"); - e.Handled = true; + case TabKey: + TryAutocompleteCommand(); + break; + + case NewlineKey: + _inputField.InsertText("\n"); + break; + + case EnterKey: + var text = _inputField.Text?.Trim() ?? string.Empty; + // Send when there's text, or when only attachments are staged (empty caption). + if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments) + && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) + { + OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text); + _inputField.Text = string.Empty; + } + break; + + case AltQKey: + _app.RequestStop(); + break; + + case CtrlKKey: + ShowSearchDialog(); + break; + + case F6Key: + // Move focus into the message list so you can select a message (arrows) and + // delete it (Delete). F6 again returns focus here. (Esc is the app quit key.) + FocusMessageList(); + break; + + case CtrlVKey: + case CtrlYKey: + // Discord-style paste priority. Copied files in the OS file manager put a file + // list (not text) on the clipboard — attach them all. Copied image data (browser + // right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text. + // Terminals never deliver either of the first two as text, so this is the only path. + if (ClipboardFiles.TryGetFiles(out var pastedFiles)) + StageFiles(pastedFiles); + else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel) + && ClipboardImage.TryGetPng(out var pastedPng)) + OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng); + else + GuardedClipboardAction(() => _inputField.Paste(), "paste"); + break; + + case CtrlXKey: + GuardedClipboardAction(() => _inputField.Cut(), "cut"); + break; + + case CtrlCKey: + GuardedClipboardAction(() => _inputField.Copy(), "copy"); + break; + + default: + return; // not one of ours — leave e.Handled false so the key types normally } + + e.Handled = true; } /// @@ -913,21 +914,25 @@ public sealed partial class MainWindow : Runnable private void OnWindowKeyDown(object? sender, Key e) { - if (e.KeyCode == AltQKey.KeyCode) + switch (e.KeyCode) { - _app.RequestStop(); - e.Handled = true; - } - else if (e.KeyCode == F2Key.KeyCode) - { - ToggleUsersPanel(); - e.Handled = true; - } - else if (e.KeyCode == CtrlKKey.KeyCode) - { - ShowSearchDialog(); - e.Handled = true; + case AltQKey: + _app.RequestStop(); + break; + + case F2Key: + ToggleUsersPanel(); + break; + + case CtrlKKey: + ShowSearchDialog(); + break; + + default: + return; } + + e.Handled = true; } private void ShowSearchDialog() From 78f18a36ba5fa8d8756e794b73b7c9747f723976 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 19:41:15 +0200 Subject: [PATCH 5/7] docs: update changelog for v0.2.14 to include clipboard image and multi-file paste features --- docs/changelog/index.md | 2 +- docs/changelog/v0.2.14.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 01022b5..d00bfba 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,7 @@ Release history for EchoHub. ## Releases -- [v0.2.14](v0.2.14.md) - E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish +- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish - [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions - [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix - [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata diff --git a/docs/changelog/v0.2.14.md b/docs/changelog/v0.2.14.md index 9cc40c8..0f581e6 100644 --- a/docs/changelog/v0.2.14.md +++ b/docs/changelog/v0.2.14.md @@ -1,9 +1,11 @@ # v0.2.14 -A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators. +A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Ctrl+V grows up too — images copied from a browser or screenshot tool paste straight into the chat as attachments, and copying several files pastes them all into one message. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators. ## New Features +- **Paste images straight from the clipboard** — copy an image from a browser, a screenshot tool (Win+Shift+S), or an image editor and Ctrl+V it into the input: it's attached as a PNG (`image.png`), no saving to disk first, Discord-style. Transparency is preserved when the source provides PNG data; plain clipboard bitmaps are converted automatically. On Linux this uses `wl-paste`/`xclip`; on macOS it requires `pngpaste`. In end-to-end encrypted rooms pasted images go through the same client-side encryption as any other attachment. +- **Multi-file paste** — copying several files in your file manager and pasting attaches them all to a single message (up to the 10-attachment cap), staged as one batch alongside anything you type as the caption. Previously each pasted file was routed through its own `/send`, which could misbehave on large batches. - **Room keys encrypted at rest** — the per-channel room keys cached so you don't retype a passphrase every launch are no longer stored as plain base64 in `config.json`. On Windows they're protected with DPAPI (current-user scope); on Linux/macOS with AES-GCM under a per-user key file created with `0600` permissions next to the config. Existing plain entries migrate to the encrypted format automatically on first load. The passphrase itself is never stored in any form. - **`[irc]` tag in the users panel** — users online only through the IRC gateway are tagged `[irc]`, useful context since IRC clients lack encryption, attachments, and profiles. Someone also running the TUI shows untagged. - **`~` marker for private channels** — the channel list now marks private (unlisted) channels with a trailing `~`, alongside the existing `*` for password-protected ones (`#room*~` when both apply). From ca3c1ec2b5807a11ef9d1553f5a7945166be23b9 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 20:02:18 +0200 Subject: [PATCH 6/7] feat: add documentation for TUI, messages & attachments, and IRC gateway; update index and TOC --- docs/articles/configuration.md | 17 ++++ docs/articles/getting-started.md | 6 +- docs/articles/irc-gateway.md | 103 +++++++++++++++++++ docs/articles/messages-and-attachments.md | 109 ++++++++++++++++++++ docs/articles/moderation.md | 71 +++++++++++++ docs/articles/toc.yml | 8 ++ docs/articles/tui-guide.md | 116 ++++++++++++++++++++++ docs/auriondocs/index.html | 20 ++++ docs/docfx.json | 4 +- docs/index.md | 4 + docs/toc.yml | 2 + 11 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 docs/articles/irc-gateway.md create mode 100644 docs/articles/messages-and-attachments.md create mode 100644 docs/articles/moderation.md create mode 100644 docs/articles/tui-guide.md create mode 100644 docs/auriondocs/index.html diff --git a/docs/articles/configuration.md b/docs/articles/configuration.md index ab860f4..af9605e 100644 --- a/docs/articles/configuration.md +++ b/docs/articles/configuration.md @@ -101,6 +101,23 @@ Access tokens expire after 15 minutes, refresh tokens after 30 days with rotatio | `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) | | `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) | +### Uploads + +Per-attachment size limits by kind (in megabytes) and the per-message attachment cap. An +absent or partial `Uploads` section keeps the built-in defaults. See +[Messages & Attachments](messages-and-attachments.md) for how kinds are detected. + +| Key | Default | Description | +| --- | --- | --- | +| `Uploads:MaxImageSizeMB` | `10` | Max size for one image attachment | +| `Uploads:MaxAudioSizeMB` | `10` | Max size for one audio attachment | +| `Uploads:MaxFileSizeMB` | `100` | Max size for any other attachment | +| `Uploads:MaxAvatarSizeMB` | `2` | Max avatar upload size | +| `Uploads:MaxAttachmentsPerMessage` | `10` | Attachments allowed on a single message | + +The server sizes its request-body limits from these values, so raising a limit here is all +that's needed — no separate Kestrel tuning. + ### Encryption | Key | Default | Description | diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index feee4ee..a0cb0b4 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst To install a specific version or to a custom directory: ```bash -curl -sSfL .../install.sh | sh -s -- --version 0.2.11 +curl -sSfL .../install.sh | sh -s -- --version 0.2.14 curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub ``` @@ -76,11 +76,11 @@ Then connect with any standard IRC client: irssi -c localhost -p 6667 -w -n ``` -IRC users must have an existing EchoHub account. Authentication works via `PASS`/`NICK`/`USER` or SASL PLAIN. Messages flow bidirectionally between IRC and TUI clients. +Your nick is your EchoHub username and the server password is your account password (`PASS`/`NICK`/`USER` or SASL PLAIN). Connecting with a new username registers the account. Messages flow bidirectionally between IRC and TUI clients. For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path. -See the [Architecture](architecture.md) page for details on how the IRC gateway integrates with the chat service. +See the [IRC Gateway guide](irc-gateway.md) for command mapping, attachment rendering, and limitations, or [Architecture](architecture.md) for how the gateway integrates with the chat service. ## Configuration diff --git a/docs/articles/irc-gateway.md b/docs/articles/irc-gateway.md new file mode 100644 index 0000000..6dfbae6 --- /dev/null +++ b/docs/articles/irc-gateway.md @@ -0,0 +1,103 @@ +# IRC Gateway + +Every EchoHub server can expose a second door: a built-in **IRC gateway** that speaks the +classic IRC protocol on port 6667. Any standard IRC client — irssi, WeeChat, HexChat, +Halloy — can join the same channels as TUI users, see the same messages, and chat with the +same accounts. Under the hood both protocols call the same chat service, so a message sent +from IRC appears instantly in the TUI and vice versa (see [Architecture](architecture.md)). + +## Enabling the gateway + +The gateway is off by default. Enable it in `appsettings.json` (or `Irc__Enabled=true` as an +environment variable): + +```json +{ + "Irc": { + "Enabled": true, + "Port": 6667, + "TlsEnabled": false, + "TlsPort": 6697, + "TlsCertPath": "", + "ServerName": "echohub", + "Motd": "Welcome to EchoHub IRC Gateway!" + } +} +``` + +The plaintext listener always starts on `Port`. The TLS listener on `TlsPort` starts only +when `TlsEnabled` is `true` **and** `TlsCertPath` points to a PKCS#12 (`.pfx`) certificate. +See the [configuration reference](configuration.md#irc-gateway) for every option. + +## Connecting & authentication + +Your IRC **nick is your EchoHub username** and your server password is your **account +password**. Two flows are supported: + +```bash +# classic PASS/NICK/USER — most clients call this the "server password" +irssi -c chat.example.com -p 6667 -w -n +``` + +or **SASL PLAIN** (advertised via `CAP LS`), where the SASL username/password are the account +credentials. + +A few things worth knowing: + +- **Connecting auto-registers.** If the username doesn't exist yet, the gateway creates the + account with that password (usernames: 3–50 chars of `a-z 0-9 _ -`; passwords: 6+ chars). + The very first account ever created on a server becomes the **Owner**. +- Because of that, a typo'd password for an *existing* account fails with + `Username is already taken` — the gateway tried to log in, couldn't, then tried to register + the name. If you see that error, re-check your password. +- Connecting without a password is rejected: `Password required. Use PASS command or SASL PLAIN.` + +## What maps to what + +| IRC | EchoHub | +| --- | --- | +| `JOIN #room` | Join a channel (history is replayed on join) | +| `JOIN #room ` | Join a password-protected (`+k`) channel | +| `PART` / `QUIT` | Leave channel / disconnect | +| `LIST` | Public channels only (password-protected ones show a `[+k]` hint) | +| `TOPIC` | Read or set the channel topic (permission-checked) | +| `NAMES` / `WHO` | Online users in the channel | +| `WHOIS` | Profile: display name, channels, idle time, away status | +| `AWAY [message]` | Sets your EchoHub status to Away / back to Online | +| `MODE #room +k ` / `-k` | Set / clear the channel password | + +Private (unlisted) channels don't appear in `LIST`, but members who know the exact name can +still `JOIN` them. Channels are not auto-created from IRC — create them from the TUI first. + +## How messages look + +- **Attachments** arrive as labeled link lines — `[Image: photo.png] https://…`, + `♪ [Audio: song.mp3] https://…`, `[File: report.pdf] https://…` — and image attachments + additionally render their **ASCII-art preview** using truecolor ANSI escapes, so a modern + terminal IRC client shows actual picture previews. +- **Link embeds** are appended as `│`-prefixed text lines. +- Long messages are split at word boundaries into IRC-safe lines (~400 bytes each); + incoming messages may be up to 2,000 characters like any EchoHub message. +- Your own messages aren't echoed back (standard IRC convention). +- Moderation actions surface natively: kicks arrive as `KICK`, bans and channel nukes as + server `NOTICE`s. + +## Limitations + +The gateway bridges what IRC can express — and deliberately refuses what it can't: + +- **No end-to-end encrypted rooms.** Joining an [encrypted room](encrypted-rooms.md) fails + with *"Cannot join channel — end-to-end encrypted, use the EchoHub client."* Bridging one + would require the server to hold the room key, breaking the zero-knowledge design. +- **No private messages.** `PRIVMSG` to a nick is rejected; EchoHub is channel-based. +- **Usernames, not display names.** Messages are attributed to the account username; + a user's display name is visible via `WHOIS`/`WHO` (realname field). +- **No client features.** Uploading attachments, profiles, themes, and reactions to status + changes are TUI-client features. Other users' status changes aren't pushed to IRC — + discover them with `WHOIS`/`WHO`. + +## How IRC users appear to TUI users + +Users connected *only* through the gateway are tagged `[irc]` in the users panel — a hint +that they can't receive encrypted content or use client-side features. Someone connected +with both an IRC client and the TUI shows untagged. diff --git a/docs/articles/messages-and-attachments.md b/docs/articles/messages-and-attachments.md new file mode 100644 index 0000000..4f47d30 --- /dev/null +++ b/docs/articles/messages-and-attachments.md @@ -0,0 +1,109 @@ +# Messages & Attachments + +An EchoHub message is **text content plus up to 10 attachments**, Discord-style. A plain chat +line is just a message with no attachments; a photo dump is one message with several files and +an optional caption. This page explains how to attach files, what happens to them on the way to +the server, and how other clients receive them. + +## Message basics + +| Limit | Value | +| --- | --- | +| Max message length | 2,000 characters | +| Max newlines per message | 30 (no blank-line runs) | +| Max attachments per message | 10 | +| Link embeds per message | first 3 URLs | + +Multiline messages are written with `Ctrl+N` for a newline; `Enter` sends. URLs in a message +get link embeds (title, description, theme color) fetched by the server. + +## Attaching files + +All of these end up in the same place — the **staging tray** — and are sent together as one +message the next time you press `Enter`, with whatever you've typed as the caption: + +- **Paste a copied file** — copy one *or several* files in your file manager and press + `Ctrl+V` in the input. All of them are staged at once. +- **Paste an image from the clipboard** — copy an image in a browser (right-click → *Copy + image*), take a screenshot (`Win+Shift+S`), or copy from an image editor, then `Ctrl+V`. + The image is attached directly as a PNG named `image.png` — no saving to disk first. + On Linux this uses `wl-paste` or `xclip`; on macOS it requires + [`pngpaste`](https://github.com/jcsalterego/pngpaste) (`brew install pngpaste`). +- **Drag & drop** — drop a file onto the terminal window; the client recognizes the dropped + path and stages the file. +- **`/send `** — stage a file by path (quote paths containing spaces). + +The input frame's title shows what's currently staged. `/clear` drops all staged attachments +without sending. Sending with an empty input is fine — the message is just the attachments. + +```text +┌ Message (2 attached: report.pdf, image.png) ──────────────┐ +│ here's the summary and a screenshot_ │ +└────────────────────────────────────────────────────────────┘ +``` + +**URL sends are different:** `/send ` sends an image URL immediately as its own +message — nothing is staged, and it isn't available in end-to-end encrypted rooms (the server +would have to fetch the image, which would defeat the encryption). + +## Attachment kinds + +The kind is detected per attachment, not per message: + +| Kind | Detected by | Renders as | Default size limit | +| --- | --- | --- | --- | +| **Image** | Magic bytes: JPEG, PNG, GIF, WebP | ASCII-art preview in chat | 10 MB | +| **Audio** | Extension: `.mp3` `.wav` `.ogg` `.flac` `.aac` `.m4a` `.wma` | Playable row (▶) | 10 MB | +| **File** | Everything else | Downloadable row | 100 MB | + +Limits are per file and server-configurable — see the `Uploads` section in the +[configuration guide](configuration.md) (`MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxFileSizeMB`, +`MaxAttachmentsPerMessage`). + +## Image previews (ASCII art) + +Images are rendered in chat as half-block ASCII art. You pick the rendering size: + +| Flag | Size | Feel | +| --- | --- | --- | +| `-s` / `/size s` | 40 × 40 | compact | +| `-m` / `/size m` | 80 × 80 | default | +| `-l` / `/size l` | 120 × 120 | detailed | + +`/size` with no argument opens a picker; the choice persists as your default. A one-off +`-s|-m|-l` flag on `/send` applies to that message. + +## Receiving attachments + +Right-click a message (or press `F6` to select one with the arrow keys) for actions: + +- **Images** → save to disk +- **Audio** → play (in-client playback) +- **Files** → download + +Downloads go to your configured download folder — set it with `/downloadpath` (no argument +opens a native folder picker, or pass a path directly). + +## Attachments in encrypted rooms + +In an [end-to-end encrypted room](encrypted-rooms.md) every attachment is encrypted +client-side **before** upload: + +```mermaid +flowchart LR + F[File bytes] -->|AES-256-GCM with room key| B[Ciphertext blob] + F -->|if image: render ASCII locally| A[ASCII preview] + A -->|room-encrypt| AP["$RC1$… preview"] + B --> S[Server stores blob + name + size] + AP --> S +``` + +The server never sees the file contents or the rendered preview — it stores an opaque blob and +broadcasts it to members, who decrypt locally. File **names and sizes remain visible** to the +server so the file list stays usable; don't put secrets in a file name. Pasted clipboard +images go through exactly the same pipeline. + +## Deleting messages with attachments + +Deleting a message also removes its uploaded attachment files from the server. You can always +delete your own messages; moderators can delete others' — see [Moderation & Roles](moderation.md). diff --git a/docs/articles/moderation.md b/docs/articles/moderation.md new file mode 100644 index 0000000..13c5ee4 --- /dev/null +++ b/docs/articles/moderation.md @@ -0,0 +1,71 @@ +# Moderation & Roles + +Every EchoHub server has a four-tier role hierarchy. Moderation is **strictly hierarchical**: +acting on another user requires outranking them — equal rank is never enough — and a few +invariants protect the server owner from lockouts. + +## Roles + +| Role | Rank | Users panel glyph | How it's granted | +| --- | --- | --- | --- | +| **Owner** | 3 | ★ | The first account ever registered on the server | +| **Admin** | 2 | ♦ | Assigned by the Owner | +| **Mod** | 1 | ❀ | Assigned by an Admin or the Owner | +| **Member** | 0 | — | Everyone else | + +Assign roles with `/role `. Two rules apply: + +- You can only assign roles **strictly below your own** — an Admin can promote to Mod but + cannot create another Admin; only the Owner can. +- **Owner is not assignable and not demotable.** There is exactly one Owner (the first + account), nobody can be promoted to it, and the Owner's role can't be changed. + +## Actions + +| Command | Minimum role | Effect | +| --- | --- | --- | +| `/kick [reason]` | Mod | Disconnects the user. Not persistent — they can reconnect immediately. | +| `/ban [reason]` | Admin | Persistent: flags the account banned and disconnects it. Banned accounts are rejected at login. | +| `/unban ` | Admin | Lifts a ban. | +| `/mute [minutes]` | Mod | Blocks the user from sending messages or uploading files. Without a duration the mute is **indefinite**; with one it auto-expires (checked every ~15 seconds). | +| `/unmute ` | Mod | Lifts a mute early. | +| `/role ` | Admin | Assign a role (see rules above). | +| `/nuke` | Mod | Deletes the **entire history of the current channel**, including all attachment files on disk. Channel-wide — no per-user check. | + +Kick, ban, and mute all enforce the hierarchy: the target's role must be **strictly lower** +than yours. A Mod cannot kick another Mod; nobody can kick, ban, mute, or demote the Owner. + +## Deleting messages + +Deletion has its own, slightly different rule set: + +- **Your own messages** — always deletable, whatever your role. Right-click a message → + *Delete message*, or press `F6`, pick the message, and hit `Delete`. +- **Someone else's messages** — requires **Mod or higher** *and* strictly outranking the + author. A Mod can delete a Member's message, but not another Mod's. + +Deleting a message also purges its uploaded attachment blobs from the server's disk, and the +removal is broadcast live — the message disappears from everyone's chat immediately. + +## How actions surface + +Everyone in the channel sees moderation happen: + +- **TUI clients** show system messages — *"alice was kicked (reason)"*, *"bob was banned"*, + *"Channel history has been cleared by a moderator."* The kicked or banned user themselves + gets a dialog with the reason, then the client disconnects. +- **IRC clients** get native protocol events: kicks arrive as a real `KICK` command, bans as + a server `NOTICE`. (See the [IRC Gateway guide](irc-gateway.md).) + +Muted users aren't announced; they simply receive *"You are muted and cannot send messages."* +when they try to speak. + +## Design notes + +- All checks run server-side in the moderation API — the client commands are conveniences, + and the same rules bind IRC users and any direct API caller. +- Bans are account-level, not IP-level. A banned person can register a fresh account; pair + bans with registration hygiene on public servers. +- In [end-to-end encrypted rooms](encrypted-rooms.md) moderation still works at the metadata + level — messages can be deleted and users muted/kicked by identity — but no moderator can + *read* the content, including the Owner. diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index c9cfe08..0c8e113 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -2,6 +2,14 @@ items: - name: Getting Started href: getting-started.md + - name: TUI Guide + href: tui-guide.md + - name: Messages & Attachments + href: messages-and-attachments.md + - name: Moderation & Roles + href: moderation.md + - name: IRC Gateway + href: irc-gateway.md - name: Docker href: docker.md - name: Architecture diff --git a/docs/articles/tui-guide.md b/docs/articles/tui-guide.md new file mode 100644 index 0000000..5103425 --- /dev/null +++ b/docs/articles/tui-guide.md @@ -0,0 +1,116 @@ +# TUI Guide + +Everything you can do in the EchoHub terminal client: keyboard shortcuts, mouse actions, +slash commands, themes, and the everyday behaviors (unread markers, auto-join, scrollback) +that make it feel like a proper IRC-era client with modern comforts. + +## Layout + +```text +┌ Menu bar ──────────────────────────────────────────────────┐ +│ ┌ Channels ─┐ ┌ Messages ────────────────────┐ ┌ Users ──┐ │ +│ │ #general 3│ │ 12:01 hi │ │ ★ alice │ │ +│ │ #dev │ │ ── new messages ── │ │ ❀ bob │ │ +│ │ #random*~ │ │ 12:04 anyone around? │ │ carol │ │ +│ └───────────┘ └──────────────────────────────┘ │ d [irc] │ │ +│ ┌ Message │ Enter=send │ Tab=complete │ … ────┐ └─────────┘ │ +│ │ _ │ │ +│ └─────────────────────────────────────────────┘ │ +│ Status: Connected │ v0.2.14 │ alice │ Act: #dev │ +└─────────────────────────────────────────────────────────────┘ +``` + +Channel list markers: `*` = password-protected, `~` = private (unlisted), plus unread counts +(orange when you were @mentioned). Users panel glyphs: `★` Owner, `♦` Admin, `❀` Mod, +`[irc]` for IRC-gateway-only users; status icons `●`/`○`/`◐`/`◌` for online/offline/away/dnd. + +## Keyboard shortcuts + +### In the message input + +| Key | Action | +| --- | --- | +| `Enter` | Send the message (also sends staged attachments with the text as caption) | +| `Ctrl+N` | Insert a newline (multiline message) | +| `Tab` | Autocomplete a slash command (`/th` → `/theme`) | +| `Ctrl+V` (or `Ctrl+Y`) | Paste — copied files and images become attachments, text pastes normally ([details](messages-and-attachments.md)) | +| `Ctrl+C` / `Ctrl+X` | Copy / cut in the input | +| `Ctrl+W` | Delete the word left of the cursor | +| `Ctrl+K` | Open the search palette | +| `F6` | Move focus into the message list | + +### In the message list (after `F6`) + +| Key | Action | +| --- | --- | +| `↑` / `↓` | Select a message | +| `Enter` | Activate: play/download/save an attachment, open an `@mention`'s profile, join a `#channel`, or open the sender's profile | +| `Delete` / `Backspace` | Delete the selected message (with confirmation; [permission rules](moderation.md)) | +| `F6` | Return focus to the input | + +### Anywhere + +| Key | Action | +| --- | --- | +| `F2` | Toggle the users panel | +| `Ctrl+K` | Search palette | +| `Alt+Q` | Quit | + +## The search palette (`Ctrl+K`) + +A command-palette that searches **channels and app actions** — type to filter, `↓` to +navigate, `Enter` to jump. Actions include Connect, Disconnect, Logout, My Profile, +Set Status, Create/Delete Channel, Saved Servers, Toggle Users Panel, Check for Updates, +and Quit. `Ctrl+K` again closes it. + +## Mouse + +- **Right-click a message** for the context menu: save image / play audio / download file + (depending on the attachment), *Mention @user*, *View profile*, *Copy text*, + *Copy message ID*, *Delete message*. +- **Left-click a message** does the most useful thing for that line: attachments + play/download/save, `@mentions` and the sender open profiles, `#channel` references join + that channel. +- **Click a user** in the users panel to open their profile; **click a channel** to switch. + +## Slash commands + +Type `/help` in any channel for the full list. The highlights: + +| Command | What it does | +| --- | --- | +| `/status ` or `/status ` | Presence / status message | +| `/nick `, `/color <#hex>`, `/avatar ` | Display name, nick color, avatar | +| `/theme ` | Switch theme | +| `/send`, `/clear`, `/size`, `/downloadpath` | Attachments — see [Messages & Attachments](messages-and-attachments.md) | +| `/join [password]`, `/leave`, `/topic ` | Channel membership and topic | +| `/passwd ` | Rotate an encrypted room's passphrase | +| `/profile [user]`, `/users`, `/meta` | Profiles, online users, room info | +| `/kick`, `/ban`, `/mute`, `/role`, `/nuke`, … | [Moderation](moderation.md) | +| `/servers`, `/quit` | Saved servers, exit | + +Emoji shortcodes (`:smile:` style) are replaced live as you type. + +## Themes + +14 built-in themes: **Default, Transparent, TransparentLight, Classic, Light, Hacker, +Solarized, Dracula, Monokai, Nord, Gruvbox, Ocean, HighContrast, RosePine** — switch from +the User menu or `/theme `. The two *Transparent* themes use no background color at +all, so your terminal's own background (and any blur/acrylic) shows through. + +You can add your own: drop a theme JSON into `~/.echohub/themes/` and it appears in the list +(names that collide with a built-in are skipped). + +## Everyday behaviors + +- **Unread markers** — a `── new messages ──` rule marks where you left off in each channel, + irssi-style. Read positions are **persisted per server**, so the marker survives + reconnects and restarts. The status bar's `Act:` segment lists channels with activity + (orange when you were @mentioned), and day boundaries draw a date rule. +- **Auto-join** — connecting joins `#general` plus every channel you're a member of, so + unread counts and mentions accumulate everywhere. Channels you `/leave` stay left, and + password-protected or [encrypted rooms](encrypted-rooms.md) are never auto-prompted — + join those explicitly. `#general` is the home channel and can't be left or deleted. +- **Scrollback** — history loads 100 messages at a time; scrolling to the top of a channel + fetches the next page and keeps your position (no jump). +- **Drag & drop** — dropping a file onto the window stages it as an attachment. diff --git a/docs/auriondocs/index.html b/docs/auriondocs/index.html new file mode 100644 index 0000000..de4844e --- /dev/null +++ b/docs/auriondocs/index.html @@ -0,0 +1,20 @@ + + + + + + AurionDocs + + + +
+

AurionDocs

+

Supplementary documentation for EchoHub.

+

← Back to EchoHub documentation

+
+ + diff --git a/docs/docfx.json b/docs/docfx.json index 7608b47..ad98ecd 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -46,7 +46,7 @@ "content": [ { "files": ["**/*.{md,yml}"], - "exclude": ["_site/**", "_api_meta/**"] + "exclude": ["_site/**", "_api_meta/**", "auriondocs/**"] }, { "src": "_api_meta/core", @@ -75,7 +75,7 @@ ], "resource": [ { - "files": ["images/**"] + "files": ["images/**", "auriondocs/**"] } ], "output": "_site", diff --git a/docs/index.md b/docs/index.md index cf75e8f..43dcb88 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,10 @@ Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat ## Quick Links - [Getting Started](articles/getting-started.md) - Set up and run EchoHub +- [TUI Guide](articles/tui-guide.md) - Keyboard shortcuts, slash commands, and everyday usage +- [Messages & Attachments](articles/messages-and-attachments.md) - Attaching, pasting, and receiving files +- [Encrypted Rooms](articles/encrypted-rooms.md) - End-to-end encrypted channels +- [IRC Gateway](articles/irc-gateway.md) - Connect with any IRC client - [Architecture](articles/architecture.md) - System design and IRC gateway - [API Reference](api/index.md) - Generated C# API documentation - [Changelog](changelog/index.md) - Release history diff --git a/docs/toc.yml b/docs/toc.yml index c93ba17..fd30a1b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -7,3 +7,5 @@ - name: API href: api/ homepage: api/index.md +- name: AurionDocs + href: auriondocs/index.html From 53927b130c7b16cda396cf02aab2534b6f643344 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 20:22:57 +0200 Subject: [PATCH 7/7] Add markdown linting tools and scripts - Create package.json to manage markdownlint-cli2 as a dev dependency. - Add PowerShell script for linting Markdown files with options for fixing issues. - Update shell script to prefer locally installed markdownlint-cli2 or fallback to npx. --- .github/workflows/ci.yml | 9 + docs/articles/messages-and-attachments.md | 2 +- package-lock.json | 1365 +++++++++++++++++++++ package.json | 12 + scripts/lint-markdown.ps1 | 61 + scripts/lint-markdown.sh | 11 +- 6 files changed, 1455 insertions(+), 5 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/lint-markdown.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82c950..65864a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install pinned linter + run: npm ci + - name: Run markdownlint run: bash scripts/lint-markdown.sh diff --git a/docs/articles/messages-and-attachments.md b/docs/articles/messages-and-attachments.md index 4f47d30..8110f6a 100644 --- a/docs/articles/messages-and-attachments.md +++ b/docs/articles/messages-and-attachments.md @@ -78,7 +78,7 @@ Images are rendered in chat as half-block ASCII art. You pick the rendering size Right-click a message (or press `F6` to select one with the arrow keys) for actions: - **Images** → save to disk -- **Audio** → play (in-client playback) +- **Audio** → play (in-client playback) - **Files** → download Downloads go to your configured download folder — set it with `/downloadpath` (no argument diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..84b7184 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1365 @@ +{ + "name": "echohub", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "echohub", + "devDependencies": { + "markdownlint-cli2": "^0.23.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.0.tgz", + "integrity": "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.0.tgz", + "integrity": "sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.2.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.0.tgz", + "integrity": "sha512-1nmgQmU/ZTMRVwYCDs7i1HI3zfBISnT2NNRv+9V01oOLZbAtqL+a7tldpPhBWBVBten3FqhMCGV6EUh9McqutQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "16.2.0", + "js-yaml": "5.2.0", + "jsonc-parser": "3.3.1", + "jsonpointer": "5.0.1", + "markdown-it": "14.2.0", + "markdownlint": "0.41.0", + "markdownlint-cli2-formatter-default": "0.0.6", + "micromatch": "4.0.8", + "smol-toml": "1.7.0" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", + "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f18489 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "echohub", + "private": true, + "description": "Repo tooling — markdown linting. Not an npm package.", + "scripts": { + "lint:md": "markdownlint-cli2", + "lint:md:fix": "markdownlint-cli2 --fix" + }, + "devDependencies": { + "markdownlint-cli2": "^0.23.0" + } +} diff --git a/scripts/lint-markdown.ps1 b/scripts/lint-markdown.ps1 new file mode 100644 index 0000000..270d419 --- /dev/null +++ b/scripts/lint-markdown.ps1 @@ -0,0 +1,61 @@ +# EchoHub - Markdown Lint +# +# Runs markdownlint-cli2 over the repo. Rules, globs, and ignores all live in +# .markdownlint-cli2.jsonc; the linter version is pinned in package.json. +# +# Usage: +# .\scripts\lint-markdown.ps1 # lint-only, exits non-zero on violations +# .\scripts\lint-markdown.ps1 -Fix # auto-fix what markdownlint can +# +# Requires: Node + npx on PATH. Prefers the locally installed linter +# (`npm install` once); otherwise npx fetches the same pinned version. + +param( + [switch]$Fix +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { + Write-Host " ERROR: npx not found on PATH. Install Node.js (https://nodejs.org) and retry." -ForegroundColor Red + exit 1 +} + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent $ScriptDir + +Push-Location $RootDir +try { + # Local install is version-pinned via package.json/package-lock.json; the + # npx fallback pins the same version so CI and local can never drift. + if (Test-Path "node_modules/.bin/markdownlint-cli2") { + $npxArgs = @('--no-install', 'markdownlint-cli2') + } else { + $npxArgs = @('--yes', 'markdownlint-cli2@0.23.0') + } + + if ($Fix) { + Write-Host " Auto-fix mode (--fix): markdownlint will rewrite files in place." -ForegroundColor Yellow + $npxArgs += '--fix' + } + + Write-Host " > npx $($npxArgs -join ' ')" -ForegroundColor Gray + & npx @npxArgs + $exit = $LASTEXITCODE + + if ($exit -eq 0) { + Write-Host " Markdown lint clean." -ForegroundColor Green + } elseif ($Fix) { + Write-Host "" + Write-Host " Some issues could not be auto-fixed. Review the output above and fix manually." -ForegroundColor Yellow + } else { + Write-Host "" + Write-Host " Markdown lint failed. Re-run with -Fix to auto-correct the fixable rules:" -ForegroundColor Red + Write-Host " .\scripts\lint-markdown.ps1 -Fix" -ForegroundColor Yellow + } + + exit $exit +} +finally { + Pop-Location +} diff --git a/scripts/lint-markdown.sh b/scripts/lint-markdown.sh index 3d6c1af..5cb0b3e 100644 --- a/scripts/lint-markdown.sh +++ b/scripts/lint-markdown.sh @@ -2,6 +2,8 @@ # # Lint all Markdown files in the repository. # Config, globs, and ignores are defined in .markdownlint-cli2.jsonc. +# The linter version is pinned in package.json — run `npm install` once, +# or just use `npm run lint:md` / `npm run lint:md:fix` directly. # # Usage: # ./scripts/lint-markdown.sh # check @@ -12,11 +14,12 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -# Resolve markdownlint-cli2 binary -if command -v markdownlint-cli2 &>/dev/null; then - LINT_CMD="markdownlint-cli2" +# Prefer the locally installed (version-pinned) linter; fall back to a one-off +# npx install of the same version pinned in package.json. +if [ -x "node_modules/.bin/markdownlint-cli2" ]; then + LINT_CMD="npx --no-install markdownlint-cli2" else - LINT_CMD="npx --yes markdownlint-cli2" + LINT_CMD="npx --yes markdownlint-cli2@0.23.0" fi echo "Linting Markdown files..."