commit 31b76ec69c79d2d251fdce9578fc2a51de8f18ca Author: alimu Date: Thu Mar 5 20:41:29 2026 +0400 Initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a326c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +obj/ +todolist.md +web-client/node_modules/ +web-client/dist/ +web-client/.vite +deploy/certs/ +deploy/keys/ diff --git a/Common/BroadcastMessage.cs b/Common/BroadcastMessage.cs new file mode 100644 index 0000000..e72a97c --- /dev/null +++ b/Common/BroadcastMessage.cs @@ -0,0 +1,66 @@ +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class BroadcastMessage : Message + { + public BroadcastMessage() + { + Type = "broadcast"; + //收到客户端消息,并执行广播 + } + public override Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.Run(() => + { + try + { + if (!UserService.IsAuthenticated(wsid)) + { + Log.Security("broadcast_denied_unauthenticated", $"wsid={wsid}"); + return; + } + + string key = Key?.Trim() ?? ""; + if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError)) + { + Log.Security("broadcast_payload_invalid", $"wsid={wsid} reason={parseError}"); + return; + } + + if (!SecurityValidator.VerifySignedMessage(wsid, Type, key, payload, out string securityReason)) + { + Log.Security("broadcast_security_failed", $"wsid={wsid} reason={securityReason}"); + return; + } + + Message response = new() + { + Type = "broadcast", + Data = payload.Payload, + Key = UserService.GetUserNameByID(wsid), + }; + + foreach (IWebSocketSession session in Sessions.Sessions) + { + if (session.ID != wsid)//不用发给自己 + { + string? publicKey = UserService.GetUserPublicKeyByID(session.ID); + if (publicKey != null) + { + string jsonString = response.ToJsonString(); + string encryptString = RsaService.EncryptForClient(publicKey, jsonString); + session.Context.WebSocket.Send(encryptString); + } + } + } + } + catch (Exception ex) + { + Log.Security("broadcast_error", $"wsid={wsid} error={ex.Message}"); + } + }); + } + } +} diff --git a/Common/ForwardMessage.cs b/Common/ForwardMessage.cs new file mode 100644 index 0000000..73dca1f --- /dev/null +++ b/Common/ForwardMessage.cs @@ -0,0 +1,79 @@ +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class ForwardMessage : Message + { + public ForwardMessage() + { + Type = "forward"; + //收到客户端消息,并执行转发 + } + public override Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.Run(() => + { + try + { + if (!UserService.IsAuthenticated(wsid)) + { + Log.Security("forward_denied_unauthenticated", $"wsid={wsid}"); + return; + } + + if (string.IsNullOrWhiteSpace(Key)) + { + Log.Security("forward_invalid_target", $"wsid={wsid}"); + return; + } + string forwardPublickKey = Key.Trim(); + + if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError)) + { + Log.Security("forward_payload_invalid", $"wsid={wsid} reason={parseError}"); + return; + } + + if (!SecurityValidator.VerifySignedMessage(wsid, Type, forwardPublickKey, payload, out string securityReason)) + { + Log.Security("forward_security_failed", $"wsid={wsid} reason={securityReason}"); + return; + } + + string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!; + + Message response = new() + { + Type = "forward", + Data = payload.Payload, + Key = fromPublicKey, + }; + + string jsonString = response.ToJsonString(); + string encryptString = RsaService.EncryptForClient(forwardPublickKey, jsonString); + + List userList = UserService.GetUserListByPublicKey(forwardPublickKey); + if (userList.Count == 0) + { + Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}"); + return; + } + + foreach (IWebSocketSession session in Sessions.Sessions) + { + if (userList.Exists(u => u.ID == session.ID)) + { + session.Context.WebSocket.Send(encryptString); + break; + } + } + } + catch (Exception ex) + { + Log.Security("forward_error", $"wsid={wsid} error={ex.Message}"); + } + }); + } + } +} diff --git a/Common/Log.cs b/Common/Log.cs new file mode 100644 index 0000000..30a6845 --- /dev/null +++ b/Common/Log.cs @@ -0,0 +1,17 @@ +namespace OnlineMsgServer.Common +{ + class Log + { + public static void Debug(string msg) + { +#if DEBUG + Console.WriteLine(msg); +#endif + } + + public static void Security(string eventName, string details) + { + Console.WriteLine($"[SECURITY] {DateTime.UtcNow:O} {eventName} {details}"); + } + } +} diff --git a/Common/Message.cs b/Common/Message.cs new file mode 100644 index 0000000..f676def --- /dev/null +++ b/Common/Message.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class Message + { + + public string Type { get; set; } + /// + /// 转发的目标 + /// + public string? Key { get; set; } + public dynamic? Data { get; set; } + public Message() + { + Type = ""; + } + + public static readonly JsonSerializerOptions options = new() + { + ReadCommentHandling = JsonCommentHandling.Skip, //允许注释 + AllowTrailingCommas = true,//允许尾随逗号 + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // 忽略 null 值 + WriteIndented = true, // 美化输出 + //PropertyNameCaseInsensitive = true,//属性名忽略大小写 + Converters = { new MessageConverter() }, + }; + + public static Message? JsonStringParse(string jsonString) + { + try + { + return JsonSerializer.Deserialize(jsonString, options); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + return null; + } + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this, options); + } + + /// + /// 指令处理逻辑(包括加密及发送过程) + /// + /// 将耗时任务交给Task以不阻塞单个连接的多个请求 + public virtual Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.CompletedTask; + } + + /// + /// 增加计时逻辑 + /// + public async Task HandlerAndMeasure(string wsid, WebSocketSessionManager Sessions) + { + Stopwatch stopWatch = new(); + stopWatch.Start(); + await Handler(wsid, Sessions); + stopWatch.Stop(); + Log.Debug($"处理{GetType()}耗时:{stopWatch.ElapsedMilliseconds}ms"); + } + + } +} \ No newline at end of file diff --git a/Common/MessageConverter.cs b/Common/MessageConverter.cs new file mode 100644 index 0000000..e29599a --- /dev/null +++ b/Common/MessageConverter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OnlineMsgServer.Common +{ + /// + /// 实现依据instruct的值决定反序列化的对象类型 + /// + class MessageConverter : JsonConverter + { + public override Message Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + JsonElement root = doc.RootElement; + if (root.TryGetProperty("type", out JsonElement typeProperty)) + { + string? instruct = typeProperty.GetString(); + Message? message = instruct switch + { + //实现新指令需在这里添加反序列化类型,限定为客户端发过来的指令类型 + // "test" => JsonSerializer.Deserialize(root.GetRawText(), options), + "publickey" => JsonSerializer.Deserialize(root.GetRawText(), options), + "forward" => JsonSerializer.Deserialize(root.GetRawText(), options), + "broadcast" => JsonSerializer.Deserialize(root.GetRawText(), options), + _ => null + }; + return message ?? throw new JsonException($"{instruct} 反序列化失败"); + } + else + { + throw new JsonException("instruct property not found"); + } + } + + public override void Write(Utf8JsonWriter writer, Message value, JsonSerializerOptions options) + { + //JsonSerializer.Serialize(writer, (object)value, options); 这个在Data是对象时导致了无限递归调用 + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + if (value.Key != null) + { + writer.WriteString("key", value.Key); + } + if (value.Data != null) + { + writer.WritePropertyName("data"); + JsonSerializer.Serialize(writer, value.Data, value.Data.GetType(), options); + } + writer.WriteEndObject(); + } + } +} \ No newline at end of file diff --git a/Common/ProtocolPayloads.cs b/Common/ProtocolPayloads.cs new file mode 100644 index 0000000..435d391 --- /dev/null +++ b/Common/ProtocolPayloads.cs @@ -0,0 +1,147 @@ +using System.Text.Json; + +namespace OnlineMsgServer.Common +{ + internal sealed class ClientRegistrationPayload + { + public string PublicKey { get; init; } = ""; + public string Challenge { get; init; } = ""; + public long Timestamp { get; init; } + public string Nonce { get; init; } = ""; + public string Signature { get; init; } = ""; + + public static bool TryParse(dynamic? data, out ClientRegistrationPayload payload, out string error) + { + payload = new ClientRegistrationPayload(); + error = "invalid data"; + + if (data is not JsonElement json || json.ValueKind != JsonValueKind.Object) + { + error = "data must be object"; + return false; + } + + if (!TryReadString(json, "publicKey", out string publicKey) || + !TryReadString(json, "challenge", out string challenge) || + !TryReadInt64(json, "timestamp", out long timestamp) || + !TryReadString(json, "nonce", out string nonce) || + !TryReadString(json, "signature", out string signature)) + { + error = "missing registration fields"; + return false; + } + + payload = new ClientRegistrationPayload + { + PublicKey = publicKey, + Challenge = challenge, + Timestamp = timestamp, + Nonce = nonce, + Signature = signature + }; + return true; + } + + public static string BuildSigningInput(string userName, string publicKey, string challenge, long timestamp, string nonce) + { + return string.Join("\n", "publickey", userName, publicKey, challenge, timestamp, nonce); + } + + private static bool TryReadString(JsonElement root, string property, out string value) + { + value = ""; + if (!root.TryGetProperty(property, out JsonElement element) || element.ValueKind != JsonValueKind.String) + { + return false; + } + + string? src = element.GetString(); + if (string.IsNullOrWhiteSpace(src)) + { + return false; + } + value = src.Trim(); + return true; + } + + private static bool TryReadInt64(JsonElement root, string property, out long value) + { + value = 0; + if (!root.TryGetProperty(property, out JsonElement element)) + { + return false; + } + return element.TryGetInt64(out value); + } + } + + internal sealed class SignedMessagePayload + { + public string Payload { get; init; } = ""; + public long Timestamp { get; init; } + public string Nonce { get; init; } = ""; + public string Signature { get; init; } = ""; + + public static bool TryParse(dynamic? data, out SignedMessagePayload payload, out string error) + { + payload = new SignedMessagePayload(); + error = "invalid data"; + + if (data is not JsonElement json || json.ValueKind != JsonValueKind.Object) + { + error = "data must be object"; + return false; + } + + if (!TryReadString(json, "payload", out string messagePayload) || + !TryReadInt64(json, "timestamp", out long timestamp) || + !TryReadString(json, "nonce", out string nonce) || + !TryReadString(json, "signature", out string signature)) + { + error = "missing signed message fields"; + return false; + } + + payload = new SignedMessagePayload + { + Payload = messagePayload, + Timestamp = timestamp, + Nonce = nonce, + Signature = signature + }; + return true; + } + + public static string BuildSigningInput(string type, string key, string payload, long timestamp, string nonce) + { + return string.Join("\n", type, key, payload, timestamp, nonce); + } + + private static bool TryReadString(JsonElement root, string property, out string value) + { + value = ""; + if (!root.TryGetProperty(property, out JsonElement element) || element.ValueKind != JsonValueKind.String) + { + return false; + } + + string? src = element.GetString(); + if (string.IsNullOrWhiteSpace(src)) + { + return false; + } + value = src; + return true; + } + + private static bool TryReadInt64(JsonElement root, string property, out long value) + { + value = 0; + if (!root.TryGetProperty(property, out JsonElement element)) + { + return false; + } + return element.TryGetInt64(out value); + } + } +} diff --git a/Common/PublicKeyMessage.cs b/Common/PublicKeyMessage.cs new file mode 100644 index 0000000..e4b2cb1 --- /dev/null +++ b/Common/PublicKeyMessage.cs @@ -0,0 +1,127 @@ +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class PublicKeyMessage : Message + { + public PublicKeyMessage() + { + Type = "publickey"; + //收到客户端公钥,添加到用户列表中,并返回自己的公钥 + } + public override Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.Run(() => + { + try + { + if (!ClientRegistrationPayload.TryParse(Data, out ClientRegistrationPayload payload, out string parseError)) + { + Log.Security("auth_payload_invalid", $"wsid={wsid} reason={parseError}"); + CloseSession(Sessions, wsid, "auth payload invalid"); + return; + } + + if (!RsaService.IsPublicKeyValid(payload.PublicKey)) + { + Log.Security("auth_publickey_invalid", $"wsid={wsid}"); + CloseSession(Sessions, wsid, "public key invalid"); + return; + } + + SecurityConfig config = SecurityRuntime.Config; + if (!SecurityValidator.IsTimestampAcceptable(payload.Timestamp, config.MaxClockSkewSeconds, config.ChallengeTtlSeconds)) + { + Log.Security("auth_timestamp_invalid", $"wsid={wsid} ts={payload.Timestamp}"); + CloseSession(Sessions, wsid, "auth timestamp invalid"); + return; + } + + if (!UserService.TryGetChallenge(wsid, out string serverChallenge, out DateTime issuedAtUtc)) + { + Log.Security("auth_challenge_missing", $"wsid={wsid}"); + CloseSession(Sessions, wsid, "challenge missing"); + return; + } + + if (!string.Equals(serverChallenge, payload.Challenge, StringComparison.Ordinal)) + { + Log.Security("auth_challenge_mismatch", $"wsid={wsid}"); + CloseSession(Sessions, wsid, "challenge mismatch"); + return; + } + + if ((DateTime.UtcNow - issuedAtUtc).TotalSeconds > config.ChallengeTtlSeconds) + { + Log.Security("auth_challenge_expired", $"wsid={wsid}"); + CloseSession(Sessions, wsid, "challenge expired"); + return; + } + + int idLength = Math.Min(wsid.Length, 8); + string userName = string.IsNullOrWhiteSpace(Key) ? $"anonymous-{wsid[..idLength]}" : Key.Trim(); + if (userName.Length > 64) + { + userName = userName[..64]; + } + + string signingInput = ClientRegistrationPayload.BuildSigningInput(userName, payload.PublicKey, payload.Challenge, payload.Timestamp, payload.Nonce); + if (!RsaService.VerifySignature(payload.PublicKey, signingInput, payload.Signature)) + { + Log.Security("auth_signature_invalid", $"wsid={wsid}"); + CloseSession(Sessions, wsid, "auth signature invalid"); + return; + } + + if (!UserService.TryRecordNonce(wsid, payload.Nonce, payload.Timestamp, config.ReplayWindowSeconds, out string nonceReason)) + { + Log.Security("auth_replay_nonce", $"wsid={wsid} reason={nonceReason}"); + CloseSession(Sessions, wsid, "auth replay detected"); + return; + } + + UserService.UserLogin(wsid, payload.PublicKey, userName); + Log.Security("auth_success", $"wsid={wsid} user={userName}"); + + Message ack = new() + { + Type = "auth_ok", + Data = "authenticated" + }; + string ackEncrypted = RsaService.EncryptForClient(payload.PublicKey, ack.ToJsonString()); + SendToSession(Sessions, wsid, ackEncrypted); + } + catch (Exception ex) + { + Log.Security("auth_error", $"wsid={wsid} error={ex.Message}"); + CloseSession(Sessions, wsid, "auth error"); + } + }); + } + + private static void SendToSession(WebSocketSessionManager sessions, string wsid, string message) + { + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == wsid) + { + session.Context.WebSocket.Send(message); + return; + } + } + } + + private static void CloseSession(WebSocketSessionManager sessions, string wsid, string reason) + { + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == wsid) + { + session.Context.WebSocket.Close(WebSocketSharp.CloseStatusCode.PolicyViolation, reason); + return; + } + } + } + } +} diff --git a/Common/User.cs b/Common/User.cs new file mode 100644 index 0000000..a30325f --- /dev/null +++ b/Common/User.cs @@ -0,0 +1,56 @@ +namespace OnlineMsgServer.Common +{ + public class User(string ID) + { + /// + /// ws连接生成的唯一uuid + /// + public string ID { get; set; } = ID; + + /// + /// 用户名,在客户端随意指定 + /// + public string? Name { get; set; } + + + /// + /// 用户公钥 用于消息加密发送给用户 + /// + public string? PublicKey { get; set; } + + /// + /// 是否通过鉴权 + /// + public bool IsAuthenticated { get; set; } + + /// + /// 连接来源IP + /// + public string? IpAddress { get; set; } + + /// + /// 服务端下发的一次性 challenge + /// + public string? PendingChallenge { get; set; } + + /// + /// challenge 下发时间(UTC) + /// + public DateTime ChallengeIssuedAtUtc { get; set; } = DateTime.UtcNow; + + /// + /// 登录成功时间(UTC) + /// + public DateTime? AuthenticatedAtUtc { get; set; } + + /// + /// 防重放 nonce 缓存(nonce -> unix timestamp) + /// + public Dictionary ReplayNonceStore { get; } = []; + + /// + /// 限流窗口内请求时间戳(unix ms) + /// + public Queue RequestTimesMs { get; } = new(); + } +} diff --git a/Core/RsaService.cs b/Core/RsaService.cs new file mode 100644 index 0000000..546b60e --- /dev/null +++ b/Core/RsaService.cs @@ -0,0 +1,174 @@ +using System.Security.Cryptography; +using System.Text; + +namespace OnlineMsgServer.Core +{ + class RsaService + { + //用于服务端加密解密 + private static readonly RSA _Rsa = RSA.Create(); + private static readonly object _RsaLock = new(); + + //用于客户端加密 + private static readonly RSA _PublicRsa = RSA.Create(); + private static readonly object _PublicRsaLock = new(); + + /// + /// 用客户端公钥加密 + /// + public static string EncryptForClient(string pkey, string msg) + { + lock (_PublicRsaLock) + { + _PublicRsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(pkey), out _); + // byte[] encrypt = _PublicRsa.Encrypt(Encoding.UTF8.GetBytes(msg), RSAEncryptionPadding.OaepSHA256); + // return Convert.ToBase64String(encrypt); + return RsaEncrypt(_PublicRsa, msg); + } + } + + /// + /// 导入服务端私钥 + /// + public static void LoadRsaPkey(SecurityConfig config) + { + lock (_RsaLock) + { + if (!string.IsNullOrWhiteSpace(config.ServerPrivateKeyBase64)) + { + _Rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(config.ServerPrivateKeyBase64), out _); + return; + } + + if (!string.IsNullOrWhiteSpace(config.ServerPrivateKeyPath)) + { + string pkey = File.ReadAllText(config.ServerPrivateKeyPath).Trim(); + _Rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(pkey), out _); + return; + } + + if (config.AllowEphemeralServerKey) + { + OnlineMsgServer.Common.Log.Security("server_key_ephemeral", "using in-memory generated private key"); + return; + } + + throw new InvalidOperationException("服务端私钥未配置。请设置 SERVER_PRIVATE_KEY_B64 或 SERVER_PRIVATE_KEY_PATH。"); + } + } + + /// + /// 以base64格式导出公钥字符串 + /// + /// 公钥字符串,base64格式 + public static string GetRsaPublickKey() + { + lock (_RsaLock) + { + return Convert.ToBase64String(_Rsa.ExportSubjectPublicKeyInfo()); + } + } + + /// + /// 服务端解密 base64编码 + /// + /// 密文 + /// 原文字符串 + public static string Decrypt(string secret) + { + lock (_RsaLock) + { + byte[] secretBytes = Convert.FromBase64String(secret); + int size = secretBytes.Length; + int blockSize = 256; + if (size % blockSize != 0) + { + throw new FormatException("ciphertext length invalid"); + } + int blockCount = size / blockSize; + List decryptList = []; + for (int i = 0; i < blockCount; i++) + { + byte[] block = new byte[blockSize]; + Array.Copy(secretBytes, i * blockSize, block, 0, blockSize); + byte[] decryptBlock = _Rsa.Decrypt(block, RSAEncryptionPadding.OaepSHA256); + decryptList.AddRange(decryptBlock); + } + + // byte[] decrypt = _Rsa.Decrypt(Convert.FromBase64String(base64), + // RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString([.. decryptList]); + } + } + + /// + /// 服务端加密 base64编码 + /// + /// 原文字符串 + /// 密文 + public static string Encrypt(string src) + { + lock (_RsaLock) + { + return RsaEncrypt(_Rsa, src); + } + } + + public static bool VerifySignature(string publicKeyBase64, string src, string signatureBase64) + { + lock (_PublicRsaLock) + { + try + { + _PublicRsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKeyBase64), out _); + byte[] srcBytes = Encoding.UTF8.GetBytes(src); + byte[] signatureBytes = Convert.FromBase64String(signatureBase64); + return _PublicRsa.VerifyData(srcBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + catch + { + return false; + } + } + } + + public static bool IsPublicKeyValid(string publicKeyBase64) + { + lock (_PublicRsaLock) + { + try + { + _PublicRsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKeyBase64), out _); + return true; + } + catch + { + return false; + } + } + } + + private static string RsaEncrypt(RSA rsa, string src) + { + byte[] srcBytes = Encoding.UTF8.GetBytes(src); + int size = srcBytes.Length; + int blockSize = 190; + if (size == 0) + { + return ""; + } + + int blockCount = (size + blockSize - 1) / blockSize; + List encryptList = []; + for (int i = 0; i < blockCount; i++) + { + int len = Math.Min(blockSize, size - i * blockSize); + byte[] block = new byte[len]; + Array.Copy(srcBytes, i * blockSize, block, 0, len); + byte[] encryptBlock = rsa.Encrypt(block, RSAEncryptionPadding.OaepSHA256); + encryptList.AddRange(encryptBlock); + } + return Convert.ToBase64String([.. encryptList]); + } + } +} diff --git a/Core/SecurityConfig.cs b/Core/SecurityConfig.cs new file mode 100644 index 0000000..518ed8b --- /dev/null +++ b/Core/SecurityConfig.cs @@ -0,0 +1,93 @@ +namespace OnlineMsgServer.Core +{ + internal sealed class SecurityConfig + { + public int ListenPort { get; init; } = 13173; + public bool RequireWss { get; init; } = false; + public string? TlsCertPath { get; init; } + public string? TlsCertPassword { get; init; } + + public string? ServerPrivateKeyBase64 { get; init; } + public string? ServerPrivateKeyPath { get; init; } + public bool AllowEphemeralServerKey { get; init; } + + public int MaxConnections { get; init; } = 1000; + public int MaxMessageBytes { get; init; } = 64 * 1024; + public int RateLimitCount { get; init; } = 30; + public int RateLimitWindowSeconds { get; init; } = 10; + public int IpBlockSeconds { get; init; } = 120; + + public int ChallengeTtlSeconds { get; init; } = 120; + public int MaxClockSkewSeconds { get; init; } = 60; + public int ReplayWindowSeconds { get; init; } = 120; + + public static SecurityConfig LoadFromEnvironment() + { + return new SecurityConfig + { + ListenPort = GetInt("LISTEN_PORT", 13173, 1), + RequireWss = GetBool("REQUIRE_WSS", false), + TlsCertPath = GetString("TLS_CERT_PATH"), + TlsCertPassword = GetString("TLS_CERT_PASSWORD"), + ServerPrivateKeyBase64 = GetString("SERVER_PRIVATE_KEY_B64"), + ServerPrivateKeyPath = GetString("SERVER_PRIVATE_KEY_PATH"), + AllowEphemeralServerKey = GetBool("ALLOW_EPHEMERAL_SERVER_KEY", false), + MaxConnections = GetInt("MAX_CONNECTIONS", 1000, 1), + MaxMessageBytes = GetInt("MAX_MESSAGE_BYTES", 64 * 1024, 512), + RateLimitCount = GetInt("RATE_LIMIT_COUNT", 30, 1), + RateLimitWindowSeconds = GetInt("RATE_LIMIT_WINDOW_SECONDS", 10, 1), + IpBlockSeconds = GetInt("IP_BLOCK_SECONDS", 120, 1), + ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10), + MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1), + ReplayWindowSeconds = GetInt("REPLAY_WINDOW_SECONDS", 120, 10), + }; + } + + private static string? GetString(string key) + { + string? value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + return value.Trim(); + } + + private static bool GetBool(string key, bool defaultValue) + { + string? value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (bool.TryParse(value, out bool parsed)) + { + return parsed; + } + + return value.Trim() switch + { + "1" => true, + "0" => false, + _ => defaultValue + }; + } + + private static int GetInt(string key, int defaultValue, int minValue) + { + string? value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (!int.TryParse(value, out int parsed)) + { + return defaultValue; + } + + return Math.Max(parsed, minValue); + } + } +} diff --git a/Core/SecurityRuntime.cs b/Core/SecurityRuntime.cs new file mode 100644 index 0000000..96d61e7 --- /dev/null +++ b/Core/SecurityRuntime.cs @@ -0,0 +1,19 @@ +namespace OnlineMsgServer.Core +{ + internal static class SecurityRuntime + { + private static readonly object _lock = new(); + + public static SecurityConfig Config { get; private set; } = SecurityConfig.LoadFromEnvironment(); + public static string? ServerCertificateFingerprintSha256 { get; private set; } + + public static void Initialize(SecurityConfig config, string? certFingerprintSha256) + { + lock (_lock) + { + Config = config; + ServerCertificateFingerprintSha256 = certFingerprintSha256; + } + } + } +} diff --git a/Core/SecurityValidator.cs b/Core/SecurityValidator.cs new file mode 100644 index 0000000..49cebdc --- /dev/null +++ b/Core/SecurityValidator.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using OnlineMsgServer.Common; + +namespace OnlineMsgServer.Core +{ + internal static class SecurityValidator + { + public static string CreateNonce(int bytes = 24) + { + byte[] buffer = RandomNumberGenerator.GetBytes(bytes); + return Convert.ToBase64String(buffer); + } + + public static bool IsTimestampAcceptable(long unixSeconds, int maxClockSkewSeconds, int replayWindowSeconds) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + long maxFuture = now + maxClockSkewSeconds; + long minPast = now - replayWindowSeconds; + return unixSeconds >= minPast && unixSeconds <= maxFuture; + } + + public static bool VerifySignedMessage(string wsid, string type, string key, SignedMessagePayload payload, out string reason) + { + reason = ""; + SecurityConfig config = SecurityRuntime.Config; + + if (!IsTimestampAcceptable(payload.Timestamp, config.MaxClockSkewSeconds, config.ReplayWindowSeconds)) + { + reason = "timestamp out of accepted window"; + return false; + } + + string? publicKey = UserService.GetUserPublicKeyByID(wsid); + if (string.IsNullOrWhiteSpace(publicKey)) + { + reason = "sender public key missing"; + return false; + } + + string signingInput = SignedMessagePayload.BuildSigningInput(type, key, payload.Payload, payload.Timestamp, payload.Nonce); + if (!RsaService.VerifySignature(publicKey, signingInput, payload.Signature)) + { + reason = "signature verify failed"; + return false; + } + + if (!UserService.TryRecordNonce(wsid, payload.Nonce, payload.Timestamp, config.ReplayWindowSeconds, out reason)) + { + return false; + } + + return true; + } + } +} diff --git a/Core/UserService.cs b/Core/UserService.cs new file mode 100644 index 0000000..ae57b5c --- /dev/null +++ b/Core/UserService.cs @@ -0,0 +1,233 @@ +using OnlineMsgServer.Common; + +namespace OnlineMsgServer.Core +{ + class UserService + { + #region 服务器用户管理 + private static readonly List _UserList = []; + private static readonly object _UserListLock = new(); + + /// + /// 通过wsid添加用户记录 + /// + public static void AddUserConnect(string wsid, string ipAddress, string challenge) + { + lock (_UserListLock) + { + User user = new(wsid); + user.IpAddress = ipAddress; + user.PendingChallenge = challenge; + user.ChallengeIssuedAtUtc = DateTime.UtcNow; + _UserList.Add(user); + } + } + /// + /// 通过wsid移除用户记录 + /// + /// + public static void RemoveUserConnectByID(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user != null) + { + _UserList.Remove(user); + } + } + } + + /// + /// 通过publickey返回用户列表 + /// + public static List GetUserListByPublicKey(string publicKey) + { + lock (_UserListLock) + { + return _UserList.FindAll(u => u.PublicKey == publicKey && u.IsAuthenticated); + } + } + + + /// + /// 通过wsid设置用户PublicKey + /// + public static void UserLogin(string wsid, string publickey, string name) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user != null) + { + user.PublicKey = publickey.Trim(); + user.Name = name.Trim(); + user.IsAuthenticated = true; + user.PendingChallenge = null; + user.AuthenticatedAtUtc = DateTime.UtcNow; + Console.WriteLine(user.ID + " 登记成功"); + } + else + { + throw new Exception("用户不存在"); + } + } + } + + /// + /// 通过wsid获取用户PublicKey + /// + public static string? GetUserPublicKeyByID(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is { IsAuthenticated: true }) + { + return user.PublicKey; + } + return null; + } + } + + /// + /// 通过wsid获取UserName + /// + public static string? GetUserNameByID(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is { IsAuthenticated: true }) + { + return user.Name; + } + return null; + } + } + + /// + /// 通过用户PublicKey获取wsid + /// + public static string? GetUserIDByPublicKey(string publicKey) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.PublicKey == publicKey && u.IsAuthenticated); + if (user != null) + { + return user.ID; + } + return null; + } + } + + public static bool IsAuthenticated(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + return user is { IsAuthenticated: true }; + } + } + + public static int GetConnectionCount() + { + lock (_UserListLock) + { + return _UserList.Count; + } + } + + public static bool TryGetChallenge(string wsid, out string challenge, out DateTime issuedAtUtc) + { + lock (_UserListLock) + { + challenge = ""; + issuedAtUtc = DateTime.MinValue; + User? user = _UserList.Find(u => u.ID == wsid); + if (user == null || string.IsNullOrWhiteSpace(user.PendingChallenge)) + { + return false; + } + + challenge = user.PendingChallenge; + issuedAtUtc = user.ChallengeIssuedAtUtc; + return true; + } + } + + public static bool TryRecordNonce(string wsid, string nonce, long timestamp, int replayWindowSeconds, out string reason) + { + lock (_UserListLock) + { + reason = ""; + User? user = _UserList.Find(u => u.ID == wsid); + if (user == null) + { + reason = "user not found"; + return false; + } + + if (string.IsNullOrWhiteSpace(nonce)) + { + reason = "nonce empty"; + return false; + } + + long cutoff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - replayWindowSeconds; + List expiredKeys = []; + foreach (KeyValuePair item in user.ReplayNonceStore) + { + if (item.Value < cutoff) + { + expiredKeys.Add(item.Key); + } + } + + foreach (string key in expiredKeys) + { + user.ReplayNonceStore.Remove(key); + } + + if (user.ReplayNonceStore.ContainsKey(nonce)) + { + reason = "replay nonce detected"; + return false; + } + + user.ReplayNonceStore[nonce] = timestamp; + return true; + } + } + + public static bool IsRateLimitExceeded(string wsid, int maxRequests, int windowSeconds) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user == null) + { + return true; + } + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long cutoff = nowMs - (windowSeconds * 1000L); + while (user.RequestTimesMs.Count > 0 && user.RequestTimesMs.Peek() < cutoff) + { + user.RequestTimesMs.Dequeue(); + } + + if (user.RequestTimesMs.Count >= maxRequests) + { + return true; + } + + user.RequestTimesMs.Enqueue(nowMs); + return false; + } + } + + #endregion + } +} diff --git a/Core/WsService.cs b/Core/WsService.cs new file mode 100644 index 0000000..fc4dbd0 --- /dev/null +++ b/Core/WsService.cs @@ -0,0 +1,139 @@ +using System.Net; +using OnlineMsgServer.Common; +using WebSocketSharp; +using WebSocketSharp.Server; +using ErrorEventArgs = WebSocketSharp.ErrorEventArgs; + +namespace OnlineMsgServer.Core +{ + class WsService : WebSocketBehavior + { + private IPEndPoint iPEndPoint = new(IPAddress.Any, 0); + private static readonly object _abuseLock = new(); + private static readonly Dictionary _ipBlockedUntil = []; + + protected override async void OnMessage(MessageEventArgs e) + { + SecurityConfig config = SecurityRuntime.Config; + try + { + string ip = iPEndPoint.Address.ToString(); + if (IsIpBlocked(ip)) + { + Common.Log.Security("message_rejected_ip_blocked", $"wsid={ID} ip={ip}"); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "ip blocked"); + return; + } + + int messageSize = e.RawData?.Length ?? 0; + if (messageSize > config.MaxMessageBytes) + { + Common.Log.Security("message_too_large", $"wsid={ID} size={messageSize}"); + BlockIp(ip, config.IpBlockSeconds); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "message too large"); + return; + } + + if (UserService.IsRateLimitExceeded(ID, config.RateLimitCount, config.RateLimitWindowSeconds)) + { + Common.Log.Security("rate_limited", $"wsid={ID} ip={ip}"); + BlockIp(ip, config.IpBlockSeconds); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "rate limited"); + return; + } + + Common.Log.Debug(ID + " " + Context.UserEndPoint.ToString() + ":" + e.Data); + //从base64字符串解密 + string decryptString = RsaService.Decrypt(e.Data); + //json 反序列化 + Message? message = Message.JsonStringParse(decryptString); + if (message != null) + { + await message.HandlerAndMeasure(ID, Sessions); + } + } + catch (Exception ex) + { + Common.Log.Security("message_process_error", $"wsid={ID} error={ex.Message}"); + } + } + + protected override void OnOpen() + { + iPEndPoint = Context.UserEndPoint; + SecurityConfig config = SecurityRuntime.Config; + string ip = iPEndPoint.Address.ToString(); + + if (IsIpBlocked(ip)) + { + Common.Log.Security("connection_blocked_ip", $"ip={ip}"); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "ip blocked"); + return; + } + + if (UserService.GetConnectionCount() >= config.MaxConnections) + { + Common.Log.Security("connection_rejected_max", $"ip={ip} max={config.MaxConnections}"); + Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "server busy"); + return; + } + + string challenge = SecurityValidator.CreateNonce(); + UserService.AddUserConnect(ID, ip, challenge); + Common.Log.Debug(ID + " " + iPEndPoint.ToString() + " Conection Open"); + //连接时回复公钥,不加密 + Message response = new() + { + Type = "publickey", + Data = new + { + publicKey = RsaService.GetRsaPublickKey(), + authChallenge = challenge, + authTtlSeconds = config.ChallengeTtlSeconds, + certFingerprintSha256 = SecurityRuntime.ServerCertificateFingerprintSha256 + }, + }; + string jsonString = response.ToJsonString(); + Send(jsonString); + } + + protected override void OnClose(CloseEventArgs e) + { + UserService.RemoveUserConnectByID(ID); + Common.Log.Debug(this.ID + " " + this.iPEndPoint.ToString() + " Conection Close" + e.Reason); + } + + protected override void OnError(ErrorEventArgs e) + { + UserService.RemoveUserConnectByID(ID); + Common.Log.Debug(this.ID + " " + this.iPEndPoint.ToString() + " Conection Error Close" + e.Message); + } + + private static bool IsIpBlocked(string ip) + { + lock (_abuseLock) + { + if (!_ipBlockedUntil.TryGetValue(ip, out DateTime untilUtc)) + { + return false; + } + + if (untilUtc <= DateTime.UtcNow) + { + _ipBlockedUntil.Remove(ip); + return false; + } + + return true; + } + } + + private static void BlockIp(string ip, int seconds) + { + lock (_abuseLock) + { + _ipBlockedUntil[ip] = DateTime.UtcNow.AddSeconds(seconds); + } + } + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d93f379 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# 使用官方的 .NET 8 SDK 镜像进行构建 +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# 设置工作目录 +WORKDIR /app + +# 将项目文件复制到容器中 +COPY . ./ + +# 恢复项目依赖项 +RUN dotnet restore + +# 编译项目 +RUN dotnet publish ./OnlineMsgServer.csproj -c Release -o out + +# 使用更小的运行时镜像 +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base + +# 设置工作目录 +WORKDIR /app + +# 运行时安全配置默认值(可在 docker run 时覆盖) +ENV REQUIRE_WSS=false \ + MAX_CONNECTIONS=1000 \ + MAX_MESSAGE_BYTES=65536 \ + RATE_LIMIT_COUNT=30 \ + RATE_LIMIT_WINDOW_SECONDS=10 \ + IP_BLOCK_SECONDS=120 \ + CHALLENGE_TTL_SECONDS=120 \ + MAX_CLOCK_SKEW_SECONDS=60 \ + REPLAY_WINDOW_SECONDS=120 + +# 创建非 root 用户 +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser + +# 暴露端口 +EXPOSE 13173 + +# 从构建镜像复制发布的应用到当前镜像 +COPY --from=build /app/out . + +# 收敛运行权限 +RUN chown -R appuser:appgroup /app +USER appuser + +# 设置容器启动命令 +ENTRYPOINT ["dotnet", "OnlineMsgServer.dll"] diff --git a/OnlineMsgServer.csproj b/OnlineMsgServer.csproj new file mode 100644 index 0000000..1eb181b --- /dev/null +++ b/OnlineMsgServer.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/OnlineMsgServer.sln b/OnlineMsgServer.sln new file mode 100644 index 0000000..f74ca6b --- /dev/null +++ b/OnlineMsgServer.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OnlineMsgServer", "OnlineMsgServer.csproj", "{25CCECC8-9D18-41C9-80CC-D4BF5DA23636}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6199C6A0-5CFC-4112-A9E7-E2B73FE4E358} + EndGlobalSection +EndGlobal diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..5ed52a2 --- /dev/null +++ b/Program.cs @@ -0,0 +1,100 @@ +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using OnlineMsgServer.Common; +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer +{ + class Program + { + static async Task Main(string[] args) + { + try + { + await MainLoop(); + } + catch (Exception e) + { + Console.WriteLine(e.Message); + } + } + + static async Task MainLoop() + { + SecurityConfig config = SecurityConfig.LoadFromEnvironment(); + string? certFingerprint = null; + + //初始化RSA + RsaService.LoadRsaPkey(config); + + var wssv = new WebSocketServer(config.ListenPort, config.RequireWss); + if (config.RequireWss) + { + X509Certificate2 certificate = LoadTlsCertificate(config); + wssv.SslConfiguration.ServerCertificate = certificate; + wssv.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13; + certFingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData)); + Console.WriteLine($"TLS cert SHA256 fingerprint: {certFingerprint}"); + } + else + { + Log.Security("transport_weak", "REQUIRE_WSS=false, service is running without TLS"); + } + + SecurityRuntime.Initialize(config, certFingerprint); + + //开启ws监听 + wssv.AddWebSocketService("/"); + wssv.Start(); + Console.WriteLine("已开启ws监听, 端口: " + config.ListenPort); + + bool loopFlag = true; + while (loopFlag) + { +#if DEBUG + Console.WriteLine("输入exit退出程序"); + string input = Console.ReadLine() ?? ""; + switch (input.Trim()) + { + case "exit": + loopFlag = false; + break; + case "port": + Console.WriteLine("服务器开放端口为" + config.ListenPort); + break; + default: + break; + } +#endif + await Task.Delay(5000);// 每5秒检查一次 + } + wssv.Stop(); + } + + static X509Certificate2 LoadTlsCertificate(SecurityConfig config) + { + if (string.IsNullOrWhiteSpace(config.TlsCertPath)) + { + throw new InvalidOperationException("启用WSS时必须配置 TLS_CERT_PATH。"); + } + + if (!File.Exists(config.TlsCertPath)) + { + throw new FileNotFoundException("找不到 TLS 证书文件。", config.TlsCertPath); + } + + X509Certificate2 cert = string.IsNullOrEmpty(config.TlsCertPassword) + ? new X509Certificate2(config.TlsCertPath) + : new X509Certificate2(config.TlsCertPath, config.TlsCertPassword); + + if (!cert.HasPrivateKey) + { + throw new InvalidOperationException("TLS 证书缺少私钥,请使用包含私钥的 PFX 证书。"); + } + + return cert; + } + } +} diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..a4a536b --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,202 @@ +# OnlineMsgServer + +在线消息中转服务(WebSocket + RSA),支持客户端鉴权、单播转发、广播、签名校验、防重放、限流。 + +## 当前默认行为 + +- 服务端默认 `REQUIRE_WSS=false`(便于内外网测试) +- 仍支持 `WSS(TLS)`,可通过环境变量启用 +- 客户端需要完成 challenge-response 鉴权后才能发业务消息 + +## 快速开始 + +### 1) 测试模式(推荐先跑通) + +```bash +cd /Users/solux/Codes/OnlineMsgServer +bash deploy/deploy_test_ws.sh +``` + +这个脚本会自动: +- 生成/复用服务端 RSA 私钥(`deploy/keys`) +- 构建镜像并重启容器 +- 以 `REQUIRE_WSS=false` 启动服务 +- 输出可直接使用的 `ws://` 地址 + +### 2) 安全模式(WSS + 局域网证书) + +```bash +cd /Users/solux/Codes/OnlineMsgServer +bash deploy/redeploy_with_lan_cert.sh +``` + +这个脚本会自动: +- 重新生成带当前 LAN IP 的 TLS 证书(`deploy/certs`) +- 构建镜像并重启容器 +- 以 `REQUIRE_WSS=true` 启动服务 +- 输出可直接使用的 `wss://` 地址 + +### 3) 生产准备(证书 + 镜像 + 部署产物) + +```bash +cd /Users/solux/Codes/OnlineMsgServer +DOMAIN=chat.example.com \ +TLS_CERT_PEM=/path/fullchain.pem \ +TLS_KEY_PEM=/path/privkey.pem \ +TLS_CHAIN_PEM=/path/chain.pem \ +CERT_PASSWORD='change-me' \ +bash deploy/prepare_prod_release.sh +``` + +脚本会自动: +- 准备服务端协议私钥(`deploy/keys`) +- 生成运行时 `server.pfx`(`deploy/certs`) +- 构建生产镜像(默认 `onlinemsgserver:prod`) +- 导出部署产物到 `deploy/output/prod`(`prod.env`、镜像 tar、运行示例脚本) + +如果你暂时没有 CA 证书,也可用自签名兜底(仅测试): + +```bash +DOMAIN=chat.example.com SAN_LIST='DNS:www.chat.example.com,IP:10.0.0.8' GENERATE_SELF_SIGNED=true bash deploy/prepare_prod_release.sh +``` + +## 手动 Docker 启动示例 + +### WS(测试) + +```bash +docker run -d --name onlinemsgserver --restart unless-stopped \ + -p 13173:13173 \ + -v /Users/solux/Codes/OnlineMsgServer/deploy/keys:/app/keys:ro \ + -e REQUIRE_WSS=false \ + -e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \ + onlinemsgserver:latest +``` + +### WSS(生产/半生产) + +```bash +docker run -d --name onlinemsgserver --restart unless-stopped \ + -p 13173:13173 \ + -v /Users/solux/Codes/OnlineMsgServer/deploy/certs:/app/certs:ro \ + -v /Users/solux/Codes/OnlineMsgServer/deploy/keys:/app/keys:ro \ + -e REQUIRE_WSS=true \ + -e TLS_CERT_PATH=/app/certs/server.pfx \ + -e TLS_CERT_PASSWORD=changeit \ + -e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \ + onlinemsgserver:latest +``` + +## 协议说明(客户端 -> 服务端) + +### 加密方式 + +- RSA-2048-OAEP-SHA256 +- 明文分块 190 字节加密 +- 密文按 256 字节分块解密 +- 传输格式为 base64 字符串 + +### 通用包结构 + +```json +{ + "type": "publickey|forward|broadcast", + "key": "", + "data": {} +} +``` + +### 1) 鉴权登记 `type=publickey` + +- `key`:用户名(可空) +- `data`: + +```json +{ + "publicKey": "客户端公钥(base64 SPKI)", + "challenge": "服务端下发挑战值", + "timestamp": 1739600000, + "nonce": "随机字符串", + "signature": "签名(base64)" +} +``` + +签名串: + +```text +publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce} +``` + +### 2) 单播转发 `type=forward` + +- `key`:目标公钥 +- `data`: + +```json +{ + "payload": "消息内容", + "timestamp": 1739600000, + "nonce": "随机字符串", + "signature": "签名(base64)" +} +``` + +签名串: + +```text +forward\n{targetPublicKey}\n{payload}\n{timestamp}\n{nonce} +``` + +### 3) 广播 `type=broadcast` + +- `key`:可空 +- `data`:同 `forward` + +签名串: + +```text +broadcast\n{key}\n{payload}\n{timestamp}\n{nonce} +``` + +### 连接流程 + +1. 客户端连接后,服务端先返回未加密 `publickey`(含服务端公钥、challenge、TTL、证书指纹)。 +2. 客户端发送签名鉴权包(`type=publickey`)。 +3. 鉴权成功后发送 `forward` / `broadcast` 业务包。 + +## 环境变量 + +- `LISTEN_PORT`:监听端口(默认 `13173`) +- `REQUIRE_WSS`:是否启用 WSS(默认 `false`) +- `TLS_CERT_PATH`:证书路径(WSS 必填) +- `TLS_CERT_PASSWORD`:证书密码(可空) +- `SERVER_PRIVATE_KEY_B64`:服务端私钥(PKCS8 base64) +- `SERVER_PRIVATE_KEY_PATH`:服务端私钥文件路径(与上面二选一) +- `ALLOW_EPHEMERAL_SERVER_KEY`:允许仅内存临时私钥(默认 `false`) +- `MAX_CONNECTIONS`:最大连接数 +- `MAX_MESSAGE_BYTES`:最大消息字节数 +- `RATE_LIMIT_COUNT`:限流窗口内最大消息数 +- `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数 +- `IP_BLOCK_SECONDS`:触发滥用后 IP 封禁秒数 +- `CHALLENGE_TTL_SECONDS`:challenge 有效期 +- `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差 +- `REPLAY_WINDOW_SECONDS`:防重放窗口 + +## 前端(React) + +前端目录:`/Users/solux/Codes/OnlineMsgServer/web-client` + +```bash +cd /Users/solux/Codes/OnlineMsgServer/web-client +npm install +npm run dev +``` + +当前前端能力: +- 默认隐藏协议细节,手动地址放在“高级连接设置” +- 支持广播/私聊、查看并复制自己的公钥 +- 每条消息支持一键复制 +- 自动处理超长消息换行,不溢出消息框 +- 用户名和客户端私钥本地持久化,刷新后继续使用 + +更多前端说明见 `web-client/README.md`。 diff --git a/android-client/.gitignore b/android-client/.gitignore new file mode 100644 index 0000000..914a29e --- /dev/null +++ b/android-client/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +local.properties +**/build/ +.idea/ +*.iml diff --git a/android-client/README.md b/android-client/README.md new file mode 100644 index 0000000..5fdb190 --- /dev/null +++ b/android-client/README.md @@ -0,0 +1,54 @@ +# OnlineMsg Android Client (Kotlin + Compose) + +本目录是针对当前 `OnlineMsgServer` 协议实现的 Android 客户端。 + +## 已实现能力 + +- Kotlin + Jetpack Compose + Material3 +- 与当前服务端协议兼容: + - 首包 `publickey` 握手(明文) + - `publickey` challenge-response 鉴权(签名) + - `broadcast` / `forward` 消息发送(签名 + 防重放字段) + - 消息体 RSA-OAEP-SHA256 分块加解密(190/256) +- Android Keystore 生成并持久化客户端 RSA 密钥 +- 状态管理:`ViewModel + StateFlow` +- 本地偏好:`DataStore`(用户名、服务器地址、模式、系统消息开关) +- 易用性: + - 广播/私聊一键切换 + - 消息复制 + - 我的公钥查看与复制 + - 服务器地址保存/删除 + - 状态提示与诊断信息 + +## 工程结构 + +- `app/src/main/java/com/onlinemsg/client/ui`:UI、ViewModel、状态模型 +- `app/src/main/java/com/onlinemsg/client/data/crypto`:RSA 加密、签名、nonce +- `app/src/main/java/com/onlinemsg/client/data/network`:WebSocket 封装 +- `app/src/main/java/com/onlinemsg/client/data/preferences`:DataStore 与地址格式化 +- `app/src/main/java/com/onlinemsg/client/data/protocol`:协议 DTO + +## 运行方式 + +1. 使用 Android Studio 打开 `android-client` 目录。 +2. 等待 Gradle Sync 完成。 +3. 运行 `app`。 + +## 联调建议 + +- 模拟器建议地址:`ws://10.0.2.2:13173/` +- 真机建议地址:`ws://<你的局域网IP>:13173/` +- 若服务端启用 WSS,需要 Android 设备信任对应证书。 + +## 协议注意事项 + +- 鉴权签名串: + - `publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce}` +- 业务签名串: + - `broadcast|forward\n{key}\n{payload}\n{timestamp}\n{nonce}` +- `forward` 的 `key` 必须是目标公钥。 +- `broadcast` 的 `key` 为空字符串。 + +## 已知限制 + +- 当前未内置证书固定(pinning);如用于公网生产,建议额外启用证书固定策略。 diff --git a/android-client/app/.DS_Store b/android-client/app/.DS_Store new file mode 100644 index 0000000..ca77567 Binary files /dev/null and b/android-client/app/.DS_Store differ diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts new file mode 100644 index 0000000..0d1f5db --- /dev/null +++ b/android-client/app/build.gradle.kts @@ -0,0 +1,84 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "com.onlinemsg.client" + compileSdk = 34 + + defaultConfig { + applicationId = "com.onlinemsg.client" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") + implementation("androidx.activity:activity-compose:1.9.2") + implementation("com.google.android.material:material:1.12.0") + + implementation(platform("androidx.compose:compose-bom:2024.09.03")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.03")) + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/android-client/app/proguard-rules.pro b/android-client/app/proguard-rules.pro new file mode 100644 index 0000000..8a93a09 --- /dev/null +++ b/android-client/app/proguard-rules.pro @@ -0,0 +1 @@ +# Keep defaults; add project-specific ProGuard rules here when needed. diff --git a/android-client/app/src/main/AndroidManifest.xml b/android-client/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..025683d --- /dev/null +++ b/android-client/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt b/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt new file mode 100644 index 0000000..a6d327a --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt @@ -0,0 +1,17 @@ +package com.onlinemsg.client + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.onlinemsg.client.ui.OnlineMsgApp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + OnlineMsgApp() + } + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/crypto/RsaCryptoManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/crypto/RsaCryptoManager.kt new file mode 100644 index 0000000..85c8940 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/crypto/RsaCryptoManager.kt @@ -0,0 +1,186 @@ +package com.onlinemsg.client.data.crypto + +import android.content.Context +import android.util.Base64 +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import java.security.spec.MGF1ParameterSpec + +class RsaCryptoManager( + context: Context +) { + data class Identity( + val publicKeyBase64: String, + val privateKey: PrivateKey + ) + + private val secureRandom = SecureRandom() + private val appContext = context.applicationContext + private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getOrCreateIdentity(): Identity { + val cachedPrivate = prefs.getString(KEY_PRIVATE_PKCS8_B64, null) + val cachedPublic = prefs.getString(KEY_PUBLIC_SPKI_B64, null) + if (!cachedPrivate.isNullOrBlank() && !cachedPublic.isNullOrBlank()) { + runCatching { + val privateKey = parsePrivateKey(cachedPrivate) + return Identity( + publicKeyBase64 = cachedPublic, + privateKey = privateKey + ) + }.onFailure { + prefs.edit() + .remove(KEY_PRIVATE_PKCS8_B64) + .remove(KEY_PUBLIC_SPKI_B64) + .apply() + } + } + + val generator = KeyPairGenerator.getInstance("RSA") + generator.initialize(KEY_SIZE_BITS) + val keyPair = generator.generateKeyPair() + val publicB64 = Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP) + val privateB64 = Base64.encodeToString(keyPair.private.encoded, Base64.NO_WRAP) + + prefs.edit() + .putString(KEY_PRIVATE_PKCS8_B64, privateB64) + .putString(KEY_PUBLIC_SPKI_B64, publicB64) + .apply() + + return Identity( + publicKeyBase64 = publicB64, + privateKey = keyPair.private + ) + } + + fun signText(privateKey: PrivateKey, text: String): String { + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(privateKey) + signature.update(text.toByteArray(StandardCharsets.UTF_8)) + return Base64.encodeToString(signature.sign(), Base64.NO_WRAP) + } + + fun encryptChunked(publicKeyBase64: String, plainText: String): String { + if (plainText.isEmpty()) return "" + val publicKey = parsePublicKey(publicKeyBase64) + + val src = plainText.toByteArray(StandardCharsets.UTF_8) + val output = ByteArrayOutputStream(src.size + 256) + var offset = 0 + while (offset < src.size) { + val len = minOf(ENCRYPT_BLOCK_SIZE, src.size - offset) + val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA256_SPEC) + val block = cipher.doFinal(src, offset, len) + output.write(block) + offset += len + } + return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP) + } + + fun decryptChunked(privateKey: PrivateKey, cipherTextBase64: String): String { + if (cipherTextBase64.isEmpty()) return "" + val encrypted = Base64.decode(cipherTextBase64, Base64.DEFAULT) + require(encrypted.size % DECRYPT_BLOCK_SIZE == 0) { + "ciphertext length invalid" + } + + val attempts = listOf<(PrivateKey, ByteArray) -> ByteArray>( + { key, block -> + val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, OAEP_SHA256_SPEC) + cipher.doFinal(block) + }, + { key, block -> + val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, OAEP_SHA256_MGF1_SHA1_SPEC) + cipher.doFinal(block) + }, + { key, block -> + val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key) + cipher.doFinal(block) + } + ) + + var lastError: Throwable? = null + for (decryptBlock in attempts) { + try { + val plainBytes = decryptBlocks(encrypted, privateKey, decryptBlock) + return String(plainBytes, StandardCharsets.UTF_8) + } catch (error: Throwable) { + lastError = error + } + } + + throw IllegalStateException("rsa oaep decrypt failed", lastError) + } + + fun createNonce(size: Int = 18): String { + val bytes = ByteArray(size) + secureRandom.nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + fun unixSecondsNow(): Long = System.currentTimeMillis() / 1000L + + private fun parsePublicKey(publicKeyBase64: String): PublicKey { + val keyBytes = Base64.decode(publicKeyBase64, Base64.DEFAULT) + val spec = X509EncodedKeySpec(keyBytes) + return KeyFactory.getInstance("RSA").generatePublic(spec) + } + + private fun parsePrivateKey(privateKeyBase64: String): PrivateKey { + val keyBytes = Base64.decode(privateKeyBase64, Base64.DEFAULT) + val spec = PKCS8EncodedKeySpec(keyBytes) + return KeyFactory.getInstance("RSA").generatePrivate(spec) + } + + private fun decryptBlocks( + encrypted: ByteArray, + privateKey: PrivateKey, + decryptBlock: (PrivateKey, ByteArray) -> ByteArray + ): ByteArray { + val output = ByteArrayOutputStream(encrypted.size) + var offset = 0 + while (offset < encrypted.size) { + val cipherBlock = encrypted.copyOfRange(offset, offset + DECRYPT_BLOCK_SIZE) + output.write(decryptBlock(privateKey, cipherBlock)) + offset += DECRYPT_BLOCK_SIZE + } + return output.toByteArray() + } + + private companion object { + private const val KEY_SIZE_BITS = 2048 + private const val ENCRYPT_BLOCK_SIZE = 190 + private const val DECRYPT_BLOCK_SIZE = 256 + private const val RSA_OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + private const val PREFS_NAME = "oms_crypto_identity" + private const val KEY_PRIVATE_PKCS8_B64 = "private_pkcs8_b64" + private const val KEY_PUBLIC_SPKI_B64 = "public_spki_b64" + private val OAEP_SHA256_SPEC: OAEPParameterSpec = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ) + private val OAEP_SHA256_MGF1_SHA1_SPEC: OAEPParameterSpec = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA1, + PSource.PSpecified.DEFAULT + ) + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt new file mode 100644 index 0000000..83f12db --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt @@ -0,0 +1,83 @@ +package com.onlinemsg.client.data.network + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString + +class OnlineMsgSocketClient { + + interface Listener { + fun onOpen() + fun onMessage(text: String) + fun onBinaryMessage(payload: ByteArray) + fun onClosed(code: Int, reason: String) + fun onFailure(throwable: Throwable) + } + + private val client = OkHttpClient.Builder() + .retryOnConnectionFailure(true) + .build() + + @Volatile + private var socket: WebSocket? = null + + @Volatile + private var listener: Listener? = null + + fun connect(url: String, listener: Listener) { + close(1000, "reconnect") + this.listener = listener + val request = Request.Builder() + .url(url) + .header("User-Agent", "OnlineMsg-Android/1.0") + .build() + socket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + this@OnlineMsgSocketClient.listener?.onOpen() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + this@OnlineMsgSocketClient.listener?.onMessage(text) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + val raw = bytes.toByteArray() + this@OnlineMsgSocketClient.listener?.onBinaryMessage(raw) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (socket === webSocket) { + socket = null + } + this@OnlineMsgSocketClient.listener?.onClosed(code, reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (socket === webSocket) { + socket = null + } + this@OnlineMsgSocketClient.listener?.onFailure(t) + } + }) + } + + fun send(text: String): Boolean = socket?.send(text) == true + + fun close(code: Int = 1000, reason: String = "manual_close") { + socket?.close(code, reason) + socket = null + } + + fun shutdown() { + close() + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/ServerUrlFormatter.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/ServerUrlFormatter.kt new file mode 100644 index 0000000..233595c --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/ServerUrlFormatter.kt @@ -0,0 +1,79 @@ +package com.onlinemsg.client.data.preferences + +import java.net.URI + +object ServerUrlFormatter { + const val maxServerUrls: Int = 8 + const val defaultServerUrl: String = "ws://10.0.2.2:13173/" + + fun normalize(input: String): String { + var value = input.trim() + if (value.isEmpty()) return "" + + if (!value.contains("://")) { + value = "ws://$value" + } + + if (value.startsWith("http://", ignoreCase = true)) { + value = "ws://${value.substring("http://".length)}" + } else if (value.startsWith("https://", ignoreCase = true)) { + value = "wss://${value.substring("https://".length)}" + } + + if (!value.endsWith('/')) { + value += "/" + } + + return try { + val uri = URI(value) + if (uri.scheme.isNullOrBlank() || uri.host.isNullOrBlank()) { + "" + } else { + value + } + } catch (_: Exception) { + "" + } + } + + fun dedupe(urls: List): List { + val result = LinkedHashSet() + urls.forEach { raw -> + val normalized = normalize(raw) + if (normalized.isNotBlank()) { + result += normalized + } + } + return result.toList() + } + + fun append(current: List, rawUrl: String): List { + val normalized = normalize(rawUrl) + if (normalized.isBlank()) return current + val merged = listOf(normalized) + current.filterNot { it == normalized } + return merged.take(maxServerUrls) + } + + fun toggleWsProtocol(rawUrl: String): String { + val normalized = normalize(rawUrl) + if (normalized.isBlank()) return "" + return try { + val uri = URI(normalized) + if (uri.scheme.equals("ws", ignoreCase = true)) { + URI( + "wss", + uri.userInfo, + uri.host, + uri.port, + uri.path, + uri.query, + uri.fragment + ).toString() + } else { + "" + } + } catch (_: Exception) { + "" + } + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt new file mode 100644 index 0000000..50e4f85 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt @@ -0,0 +1,123 @@ +package com.onlinemsg.client.data.preferences + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonArray + +private val Context.dataStore by preferencesDataStore(name = "oms_preferences") + +data class UserPreferences( + val displayName: String, + val serverUrls: List, + val currentServerUrl: String, + val showSystemMessages: Boolean, + val directMode: Boolean +) + +class UserPreferencesRepository( + private val context: Context, + private val json: Json +) { + val preferencesFlow: Flow = context.dataStore.data.map { prefs -> + val storedDisplayName = prefs[KEY_DISPLAY_NAME].orEmpty().trim() + val displayName = storedDisplayName.takeIf { it.isNotBlank() } ?: "guest-${System.currentTimeMillis().toString().takeLast(6)}" + + val serverUrls = decodeServerUrls(prefs[KEY_SERVER_URLS]) + val currentServer = ServerUrlFormatter.normalize(prefs[KEY_CURRENT_SERVER_URL].orEmpty()) + .takeIf { it.isNotBlank() } + ?: serverUrls.firstOrNull() + ?: ServerUrlFormatter.defaultServerUrl + + UserPreferences( + displayName = displayName.take(64), + serverUrls = if (serverUrls.isEmpty()) listOf(ServerUrlFormatter.defaultServerUrl) else serverUrls, + currentServerUrl = currentServer, + showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false, + directMode = prefs[KEY_DIRECT_MODE] ?: false + ) + } + + suspend fun setDisplayName(name: String) { + context.dataStore.edit { prefs -> + prefs[KEY_DISPLAY_NAME] = name.trim().take(64) + } + } + + suspend fun setCurrentServerUrl(rawUrl: String) { + val normalized = ServerUrlFormatter.normalize(rawUrl) + if (normalized.isBlank()) return + context.dataStore.edit { prefs -> + prefs[KEY_CURRENT_SERVER_URL] = normalized + val currentList = decodeServerUrls(prefs[KEY_SERVER_URLS]) + val nextList = ServerUrlFormatter.append(currentList, normalized) + prefs[KEY_SERVER_URLS] = json.encodeToString(nextList) + } + } + + suspend fun saveCurrentServerUrl(rawUrl: String) { + val normalized = ServerUrlFormatter.normalize(rawUrl) + if (normalized.isBlank()) return + context.dataStore.edit { prefs -> + val currentList = decodeServerUrls(prefs[KEY_SERVER_URLS]) + val nextList = ServerUrlFormatter.append(currentList, normalized) + prefs[KEY_SERVER_URLS] = json.encodeToString(nextList) + prefs[KEY_CURRENT_SERVER_URL] = normalized + } + } + + suspend fun removeCurrentServerUrl(rawUrl: String) { + val normalized = ServerUrlFormatter.normalize(rawUrl) + context.dataStore.edit { prefs -> + val currentList = decodeServerUrls(prefs[KEY_SERVER_URLS]) + val filtered = currentList.filterNot { it == normalized } + val nextList = if (filtered.isEmpty()) { + listOf(ServerUrlFormatter.defaultServerUrl) + } else { + filtered + } + prefs[KEY_SERVER_URLS] = json.encodeToString(nextList) + prefs[KEY_CURRENT_SERVER_URL] = nextList.first() + } + } + + suspend fun setShowSystemMessages(show: Boolean) { + context.dataStore.edit { prefs -> + prefs[KEY_SHOW_SYSTEM_MESSAGES] = show + } + } + + suspend fun setDirectMode(isDirect: Boolean) { + context.dataStore.edit { prefs -> + prefs[KEY_DIRECT_MODE] = isDirect + } + } + + private fun decodeServerUrls(raw: String?): List { + if (raw.isNullOrBlank()) return listOf(ServerUrlFormatter.defaultServerUrl) + return runCatching { + val element = json.parseToJsonElement(raw) + element.jsonArray.mapNotNull { item -> + runCatching { json.decodeFromJsonElement(item) }.getOrNull() + } + }.getOrElse { emptyList() } + .let(ServerUrlFormatter::dedupe) + .ifEmpty { listOf(ServerUrlFormatter.defaultServerUrl) } + } + + private companion object { + val KEY_DISPLAY_NAME: Preferences.Key = stringPreferencesKey("display_name") + val KEY_SERVER_URLS: Preferences.Key = stringPreferencesKey("server_urls") + val KEY_CURRENT_SERVER_URL: Preferences.Key = stringPreferencesKey("current_server_url") + val KEY_SHOW_SYSTEM_MESSAGES: Preferences.Key = booleanPreferencesKey("show_system_messages") + val KEY_DIRECT_MODE: Preferences.Key = booleanPreferencesKey("direct_mode") + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt new file mode 100644 index 0000000..deb69d1 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt @@ -0,0 +1,54 @@ +package com.onlinemsg.client.data.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +data class EnvelopeDto( + @SerialName("type") val type: String, + @SerialName("key") val key: String? = null, + @SerialName("data") val data: JsonElement? = JsonNull +) + +@Serializable +data class HelloDataDto( + @SerialName("publicKey") val publicKey: String, + @SerialName("authChallenge") val authChallenge: String, + @SerialName("authTtlSeconds") val authTtlSeconds: Int? = null, + @SerialName("certFingerprintSha256") val certFingerprintSha256: String? = null +) + +@Serializable +data class AuthPayloadDto( + @SerialName("publicKey") val publicKey: String, + @SerialName("challenge") val challenge: String, + @SerialName("timestamp") val timestamp: Long, + @SerialName("nonce") val nonce: String, + @SerialName("signature") val signature: String +) + +@Serializable +data class SignedPayloadDto( + @SerialName("payload") val payload: String, + @SerialName("timestamp") val timestamp: Long, + @SerialName("nonce") val nonce: String, + @SerialName("signature") val signature: String +) + +fun JsonElement?.asPayloadText(): String { + if (this == null || this is JsonNull) return "" + return if (this is JsonPrimitive && this.isString) { + this.content + } else { + this.toString() + } +} + +fun JsonElement?.asStringOrNull(): String? { + if (this == null || this is JsonNull) return null + return runCatching { this.jsonPrimitive.content }.getOrNull() +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt new file mode 100644 index 0000000..95e5255 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -0,0 +1,589 @@ +package com.onlinemsg.client.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.onlinemsg.client.ui.theme.OnlineMsgTheme +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private enum class MainTab(val label: String) { + CHAT("聊天"), + SETTINGS("设置") +} + +@Composable +fun OnlineMsgApp( + viewModel: ChatViewModel = viewModel() +) { + OnlineMsgTheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message) + } + } + } + + Scaffold( + topBar = { + AppTopBar( + statusText = state.statusText, + statusColor = when (state.status) { + ConnectionStatus.READY -> MaterialTheme.colorScheme.primary + ConnectionStatus.ERROR -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.secondary + } + ) + }, + bottomBar = { + NavigationBar(modifier = Modifier.navigationBarsPadding()) { + NavigationBarItem( + selected = tab == MainTab.CHAT, + onClick = { tab = MainTab.CHAT }, + label = { Text(MainTab.CHAT.label) }, + icon = {} + ) + NavigationBarItem( + selected = tab == MainTab.SETTINGS, + onClick = { tab = MainTab.SETTINGS }, + label = { Text(MainTab.SETTINGS.label) }, + icon = {} + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { innerPadding -> + when (tab) { + MainTab.CHAT -> { + ChatTab( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + state = state, + onToggleDirectMode = viewModel::toggleDirectMode, + onTargetKeyChange = viewModel::updateTargetKey, + onDraftChange = viewModel::updateDraft, + onSend = viewModel::sendMessage, + onConnect = viewModel::connect, + onDisconnect = viewModel::disconnect, + onClearMessages = viewModel::clearMessages, + onCopyMessage = { content -> + clipboard.setText(AnnotatedString(content)) + viewModel.onMessageCopied() + } + ) + } + + MainTab.SETTINGS -> { + SettingsTab( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + state = state, + onDisplayNameChange = viewModel::updateDisplayName, + onServerUrlChange = viewModel::updateServerUrl, + onSaveServer = viewModel::saveCurrentServerUrl, + onRemoveServer = viewModel::removeCurrentServerUrl, + onSelectServer = viewModel::updateServerUrl, + onToggleShowSystem = viewModel::toggleShowSystemMessages, + onRevealPublicKey = viewModel::revealMyPublicKey, + onCopyPublicKey = { + if (state.myPublicKey.isNotBlank()) { + clipboard.setText(AnnotatedString(state.myPublicKey)) + viewModel.onMessageCopied() + } + }, + onConnect = viewModel::connect, + onDisconnect = viewModel::disconnect, + onClearMessages = viewModel::clearMessages + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppTopBar( + statusText: String, + statusColor: Color +) { + TopAppBar( + title = { + Text( + text = "OnlineMsg Chat", + style = MaterialTheme.typography.titleLarge + ) + }, + actions = { + AssistChip( + onClick = {}, + label = { Text(statusText) }, + leadingIcon = { + Box( + modifier = Modifier + .width(10.dp) + .height(10.dp) + .background(statusColor, RoundedCornerShape(999.dp)) + ) + } + ) + Spacer(modifier = Modifier.width(12.dp)) + } + ) +} + +@Composable +private fun ChatTab( + modifier: Modifier, + state: ChatUiState, + onToggleDirectMode: (Boolean) -> Unit, + onTargetKeyChange: (String) -> Unit, + onDraftChange: (String) -> Unit, + onSend: () -> Unit, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onClearMessages: () -> Unit, + onCopyMessage: (String) -> Unit +) { + val listState = rememberLazyListState() + + LaunchedEffect(state.visibleMessages.size) { + if (state.visibleMessages.isNotEmpty()) { + listState.animateScrollToItem(state.visibleMessages.lastIndex) + } + } + + Column( + modifier = modifier + .fillMaxSize() + .imePadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + ConnectionRow( + statusHint = state.statusHint, + canConnect = state.canConnect, + canDisconnect = state.canDisconnect, + onConnect = onConnect, + onDisconnect = onDisconnect, + onClearMessages = onClearMessages + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FilterChip( + selected = !state.directMode, + onClick = { onToggleDirectMode(false) }, + label = { Text("广播") } + ) + FilterChip( + selected = state.directMode, + onClick = { onToggleDirectMode(true) }, + label = { Text("私聊") } + ) + } + + if (state.directMode) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = state.targetKey, + onValueChange = onTargetKeyChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("目标公钥") }, + placeholder = { Text("私聊模式:粘贴目标公钥") }, + maxLines = 3 + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + state = listState, + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (state.visibleMessages.isEmpty()) { + item { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text( + text = "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(state.visibleMessages, key = { it.id }) { message -> + MessageItem( + message = message, + onCopy = { onCopyMessage(message.content) } + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = state.draft, + onValueChange = onDraftChange, + modifier = Modifier.weight(1f), + label = { Text("输入消息") }, + maxLines = 4, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { onSend() } + ) + ) + + Button( + onClick = onSend, + enabled = state.canSend, + modifier = Modifier.height(56.dp) + ) { + Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = "发送") + Spacer(Modifier.width(6.dp)) + Text(if (state.sending) "发送中" else "发送") + } + } + } +} + +@Composable +private fun ConnectionRow( + statusHint: String, + canConnect: Boolean, + canDisconnect: Boolean, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onClearMessages: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "在线会话", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = statusHint, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onConnect, enabled = canConnect) { + Text("连接") + } + OutlinedButton(onClick = onDisconnect, enabled = canDisconnect) { + Text("断开") + } + OutlinedButton(onClick = onClearMessages) { + Text("清空") + } + } + } + } +} + +@Composable +private fun MessageItem( + message: UiMessage, + onCopy: () -> Unit +) { + val container = when (message.role) { + MessageRole.SYSTEM -> MaterialTheme.colorScheme.secondaryContainer + MessageRole.INCOMING -> MaterialTheme.colorScheme.surface + MessageRole.OUTGOING -> MaterialTheme.colorScheme.primaryContainer + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = container) + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (message.role != MessageRole.SYSTEM) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = message.sender, + style = MaterialTheme.typography.labelLarge + ) + if (message.subtitle.isNotBlank()) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = message.subtitle, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = formatTime(message.timestampMillis), + style = MaterialTheme.typography.labelSmall + ) + } + } + + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium + ) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + IconButton(onClick = onCopy) { + Icon(Icons.Rounded.ContentCopy, contentDescription = "复制") + } + } + } + } +} + +@Composable +private fun SettingsTab( + modifier: Modifier, + state: ChatUiState, + onDisplayNameChange: (String) -> Unit, + onServerUrlChange: (String) -> Unit, + onSaveServer: () -> Unit, + onRemoveServer: () -> Unit, + onSelectServer: (String) -> Unit, + onToggleShowSystem: (Boolean) -> Unit, + onRevealPublicKey: () -> Unit, + onCopyPublicKey: () -> Unit, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onClearMessages: () -> Unit +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Card { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("个人设置", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = state.displayName, + onValueChange = onDisplayNameChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("显示名称") }, + supportingText = { Text("最长 64 字符") }, + maxLines = 1 + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onConnect, enabled = state.canConnect) { + Text("连接") + } + OutlinedButton(onClick = onDisconnect, enabled = state.canDisconnect) { + Text("断开") + } + OutlinedButton(onClick = onClearMessages) { + Text("清空消息") + } + } + } + } + } + + item { + Card { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("服务器", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = state.serverUrl, + onValueChange = onServerUrlChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("服务器地址") }, + placeholder = { Text("ws://10.0.2.2:13173/") }, + maxLines = 1 + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onSaveServer) { + Text("保存地址") + } + OutlinedButton(onClick = onRemoveServer) { + Text("删除当前") + } + } + if (state.serverUrls.isNotEmpty()) { + HorizontalDivider() + Text("已保存地址", style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.serverUrls) { url -> + AssistChip( + onClick = { onSelectServer(url) }, + label = { + Text( + text = url, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + } + } + } + } + + item { + Card { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("身份与安全", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onRevealPublicKey, + enabled = !state.loadingPublicKey + ) { + Text(if (state.loadingPublicKey) "读取中" else "查看/生成公钥") + } + OutlinedButton( + onClick = onCopyPublicKey, + enabled = state.myPublicKey.isNotBlank() + ) { + Text("复制公钥") + } + } + OutlinedTextField( + value = state.myPublicKey, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + readOnly = true, + label = { Text("我的公钥") }, + placeholder = { Text("点击“查看/生成公钥”") }, + maxLines = 4 + ) + } + } + } + + item { + Card { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("诊断", style = MaterialTheme.typography.titleMedium) + Text("连接提示:${state.statusHint}") + Text("当前状态:${state.statusText}") + Text("证书指纹:${state.certFingerprint.ifBlank { "未获取" }}") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Switch( + checked = state.showSystemMessages, + onCheckedChange = onToggleShowSystem + ) + Text("显示系统消息") + } + } + } + } + + item { + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +private fun formatTime(tsMillis: Long): String { + val formatter = DateTimeFormatter.ofPattern("HH:mm") + return Instant.ofEpochMilli(tsMillis) + .atZone(ZoneId.systemDefault()) + .toLocalTime() + .format(formatter) +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt new file mode 100644 index 0000000..52b4785 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -0,0 +1,86 @@ +package com.onlinemsg.client.ui + +import java.util.UUID + +enum class ConnectionStatus { + IDLE, + CONNECTING, + HANDSHAKING, + AUTHENTICATING, + READY, + ERROR +} + +enum class MessageRole { + SYSTEM, + INCOMING, + OUTGOING +} + +enum class MessageChannel { + BROADCAST, + PRIVATE +} + +data class UiMessage( + val id: String = UUID.randomUUID().toString(), + val role: MessageRole, + val sender: String, + val subtitle: String = "", + val content: String, + val channel: MessageChannel, + val timestampMillis: Long = System.currentTimeMillis() +) + +data class ChatUiState( + val status: ConnectionStatus = ConnectionStatus.IDLE, + val statusHint: String = "点击连接开始聊天", + val displayName: String = "", + val serverUrls: List = emptyList(), + val serverUrl: String = "", + val directMode: Boolean = false, + val targetKey: String = "", + val draft: String = "", + val messages: List = emptyList(), + val showSystemMessages: Boolean = false, + val certFingerprint: String = "", + val myPublicKey: String = "", + val sending: Boolean = false, + val loadingPublicKey: Boolean = false +) { + val canConnect: Boolean + get() = status == ConnectionStatus.IDLE || status == ConnectionStatus.ERROR + + val canDisconnect: Boolean + get() = status == ConnectionStatus.CONNECTING || + status == ConnectionStatus.HANDSHAKING || + status == ConnectionStatus.AUTHENTICATING || + status == ConnectionStatus.READY + + val canSend: Boolean + get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending + + val statusText: String + get() = when (status) { + ConnectionStatus.IDLE -> "未连接" + ConnectionStatus.CONNECTING, + ConnectionStatus.HANDSHAKING, + ConnectionStatus.AUTHENTICATING -> "连接中" + ConnectionStatus.READY -> "已连接" + ConnectionStatus.ERROR -> "异常断开" + } + + val visibleMessages: List + get() = messages.filter { item -> + if (item.role == MessageRole.SYSTEM) { + showSystemMessages + } else { + val selected = if (directMode) MessageChannel.PRIVATE else MessageChannel.BROADCAST + item.channel == selected + } + } +} + +sealed interface UiEvent { + data class ShowSnackbar(val message: String) : UiEvent +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt new file mode 100644 index 0000000..97861fa --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -0,0 +1,838 @@ +package com.onlinemsg.client.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.onlinemsg.client.data.crypto.RsaCryptoManager +import com.onlinemsg.client.data.network.OnlineMsgSocketClient +import com.onlinemsg.client.data.preferences.ServerUrlFormatter +import com.onlinemsg.client.data.preferences.UserPreferencesRepository +import com.onlinemsg.client.data.protocol.AuthPayloadDto +import com.onlinemsg.client.data.protocol.EnvelopeDto +import com.onlinemsg.client.data.protocol.HelloDataDto +import com.onlinemsg.client.data.protocol.SignedPayloadDto +import com.onlinemsg.client.data.protocol.asPayloadText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +class ChatViewModel(application: Application) : AndroidViewModel(application) { + + private val json = Json { + ignoreUnknownKeys = true + } + + private val preferencesRepository = UserPreferencesRepository(application, json) + private val cryptoManager = RsaCryptoManager(application) + private val socketClient = OnlineMsgSocketClient() + + private val _uiState = MutableStateFlow(ChatUiState()) + val uiState = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + private val identityMutex = Mutex() + private var identity: RsaCryptoManager.Identity? = null + + private var manualClose = false + private var fallbackTried = false + private var connectedUrl = "" + private var serverPublicKey = "" + private var helloTimeoutJob: Job? = null + private var authTimeoutJob: Job? = null + private var reconnectJob: Job? = null + private var reconnectAttempt: Int = 0 + private val systemMessageExpiryJobs: MutableMap = mutableMapOf() + + private val socketListener = object : OnlineMsgSocketClient.Listener { + override fun onOpen() { + viewModelScope.launch { + _uiState.update { + it.copy( + status = ConnectionStatus.HANDSHAKING, + statusHint = "已连接,正在准备聊天..." + ) + } + addSystemMessage("连接已建立") + startHelloTimeout() + } + } + + override fun onMessage(text: String) { + viewModelScope.launch { + runCatching { + handleIncomingMessage(text) + }.onFailure { error -> + addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}") + } + } + } + + override fun onBinaryMessage(payload: ByteArray) { + viewModelScope.launch { + if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { + _uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") } + } + + val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty() + if (utf8.isNotBlank()) { + runCatching { + handleIncomingMessage(utf8) + }.onFailure { error -> + addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}") + } + } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { + val hexPreview = payload.take(24).joinToString(" ") { byte -> + "%02x".format(byte) + } + addSystemMessage("握手二进制帧无法转为文本,len=${payload.size} hex=$hexPreview") + } + } + } + + override fun onClosed(code: Int, reason: String) { + viewModelScope.launch { + handleSocketClosed(code, reason) + } + } + + override fun onFailure(throwable: Throwable) { + viewModelScope.launch { + if (manualClose) return@launch + val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown" + addSystemMessage("连接异常:$message") + if (_uiState.value.status == ConnectionStatus.READY) { + scheduleReconnect("连接异常") + } else { + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "连接失败,请检查服务器地址" + ) + } + } + } + } + } + + init { + viewModelScope.launch { + val pref = preferencesRepository.preferencesFlow.first() + _uiState.update { current -> + current.copy( + displayName = pref.displayName, + serverUrls = pref.serverUrls, + serverUrl = pref.currentServerUrl, + directMode = pref.directMode, + showSystemMessages = pref.showSystemMessages + ) + } + } + } + + fun updateDisplayName(value: String) { + val displayName = value.take(64) + _uiState.update { it.copy(displayName = displayName) } + viewModelScope.launch { + preferencesRepository.setDisplayName(displayName) + } + } + + fun updateServerUrl(value: String) { + _uiState.update { it.copy(serverUrl = value) } + } + + fun updateTargetKey(value: String) { + _uiState.update { it.copy(targetKey = value) } + } + + fun updateDraft(value: String) { + _uiState.update { it.copy(draft = value) } + } + + fun toggleDirectMode(enabled: Boolean) { + _uiState.update { it.copy(directMode = enabled) } + viewModelScope.launch { + preferencesRepository.setDirectMode(enabled) + } + } + + fun toggleShowSystemMessages(show: Boolean) { + _uiState.update { it.copy(showSystemMessages = show) } + viewModelScope.launch { + preferencesRepository.setShowSystemMessages(show) + } + } + + fun clearMessages() { + cancelSystemMessageExpiryJobs() + _uiState.update { it.copy(messages = emptyList()) } + } + + fun saveCurrentServerUrl() { + val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) + if (normalized.isBlank()) { + viewModelScope.launch { + _events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址")) + } + return + } + + val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized) + _uiState.update { + it.copy( + serverUrl = normalized, + serverUrls = nextUrls, + statusHint = "服务器地址已保存" + ) + } + + viewModelScope.launch { + preferencesRepository.saveCurrentServerUrl(normalized) + _events.emit(UiEvent.ShowSnackbar("服务器地址已保存")) + } + } + + fun removeCurrentServerUrl() { + val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl) + if (normalized.isBlank()) return + + val filtered = _uiState.value.serverUrls.filterNot { it == normalized } + val nextUrls = if (filtered.isEmpty()) { + listOf(ServerUrlFormatter.defaultServerUrl) + } else { + filtered + } + + _uiState.update { + it.copy( + serverUrls = nextUrls, + serverUrl = nextUrls.first(), + statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址" + ) + } + + viewModelScope.launch { + preferencesRepository.removeCurrentServerUrl(normalized) + _events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表")) + } + } + + fun revealMyPublicKey() { + viewModelScope.launch { + _uiState.update { it.copy(loadingPublicKey = true) } + runCatching { + ensureIdentity() + }.onSuccess { id -> + _uiState.update { + it.copy( + myPublicKey = id.publicKeyBase64, + loadingPublicKey = false + ) + } + }.onFailure { error -> + _uiState.update { it.copy(loadingPublicKey = false) } + _events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}")) + } + } + } + + fun connect() { + val state = _uiState.value + if (!state.canConnect) return + + val normalized = ServerUrlFormatter.normalize(state.serverUrl) + if (normalized.isBlank()) { + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "请填写有效服务器地址" + ) + } + return + } + + manualClose = false + fallbackTried = false + connectedUrl = normalized + serverPublicKey = "" + cancelReconnect() + reconnectAttempt = 0 + cancelHelloTimeout() + cancelAuthTimeout() + + _uiState.update { + it.copy( + status = ConnectionStatus.CONNECTING, + statusHint = "正在连接服务器...", + serverUrl = normalized, + certFingerprint = "" + ) + } + + viewModelScope.launch { + preferencesRepository.setCurrentServerUrl(normalized) + } + + socketClient.connect(normalized, socketListener) + } + + fun disconnect() { + manualClose = true + cancelReconnect() + cancelHelloTimeout() + cancelAuthTimeout() + socketClient.close(1000, "manual_close") + _uiState.update { + it.copy( + status = ConnectionStatus.IDLE, + statusHint = "连接已关闭" + ) + } + addSystemMessage("已断开连接") + } + + fun sendMessage() { + val current = _uiState.value + if (!current.canSend) return + + viewModelScope.launch { + val text = _uiState.value.draft.trim() + if (text.isBlank()) return@launch + + val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else "" + if (_uiState.value.directMode && key.isBlank()) { + _uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") } + return@launch + } + + val type = if (key.isBlank()) "broadcast" else "forward" + val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE + val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}" + + _uiState.update { it.copy(sending = true) } + + runCatching { + val id = ensureIdentity() + val timestamp = cryptoManager.unixSecondsNow() + val nonce = cryptoManager.createNonce() + val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n") + val signature = withContext(Dispatchers.Default) { + cryptoManager.signText(id.privateKey, signingInput) + } + + val payload = SignedPayloadDto( + payload = text, + timestamp = timestamp, + nonce = nonce, + signature = signature + ) + val envelope = EnvelopeDto( + type = type, + key = key, + data = json.encodeToJsonElement(payload) + ) + + val plain = json.encodeToString(envelope) + val cipher = withContext(Dispatchers.Default) { + cryptoManager.encryptChunked(serverPublicKey, plain) + } + + check(socketClient.send(cipher)) { "连接不可用" } + }.onSuccess { + addOutgoingMessage(text, subtitle, channel) + _uiState.update { it.copy(draft = "", sending = false) } + }.onFailure { error -> + _uiState.update { it.copy(sending = false) } + addSystemMessage("发送失败:${error.message ?: "unknown"}") + } + } + } + + fun onMessageCopied() { + viewModelScope.launch { + _events.emit(UiEvent.ShowSnackbar("已复制")) + } + } + + private suspend fun ensureIdentity(): RsaCryptoManager.Identity { + return identityMutex.withLock { + identity ?: withContext(Dispatchers.Default) { + cryptoManager.getOrCreateIdentity() + }.also { created -> + identity = created + } + } + } + + private suspend fun handleIncomingMessage(rawText: String) { + if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { + _uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") } + } + + val normalizedText = extractJsonCandidate(rawText) + val rootObject = runCatching { + json.decodeFromString(normalizedText) as? JsonObject + }.getOrNull() + + // 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层) + val directHello = rootObject?.let { obj -> + val hasPublicKey = obj["publicKey"] != null + val hasChallenge = obj["authChallenge"] != null + if (hasPublicKey && hasChallenge) { + runCatching { json.decodeFromJsonElement(obj) }.getOrNull() + } else { + null + } + } + if (directHello != null) { + cancelHelloTimeout() + handleServerHello(directHello) + return + } + + val plain = runCatching { json.decodeFromString(normalizedText) }.getOrNull() + if (plain?.type == "publickey") { + cancelHelloTimeout() + val hello = plain.data?.let { + runCatching { json.decodeFromJsonElement(it) }.getOrNull() + } + if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) { + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "握手失败:服务端响应不完整" + ) + } + return + } + handleServerHello(hello) + return + } + + if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) { + _uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") } + addSystemMessage("握手阶段收到非预期消息类型:${plain.type}") + } else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) { + val preview = rawText + .replace("\n", " ") + .replace("\r", " ") + .take(80) + _uiState.update { it.copy(statusHint = "握手失败:首包解析失败") } + addSystemMessage("握手包解析失败:$preview") + } + + val id = ensureIdentity() + val decrypted = runCatching { + withContext(Dispatchers.Default) { + cryptoManager.decryptChunked(id.privateKey, normalizedText) + } + }.getOrElse { + addSystemMessage("收到无法解密的消息") + return + } + + val secure = runCatching { + json.decodeFromString(decrypted) + }.getOrNull() ?: return + + handleSecureMessage(secure) + } + + private suspend fun handleServerHello(hello: HelloDataDto) { + cancelHelloTimeout() + serverPublicKey = hello.publicKey + _uiState.update { + it.copy( + status = ConnectionStatus.AUTHENTICATING, + statusHint = "正在完成身份验证...", + certFingerprint = hello.certFingerprintSha256.orEmpty() + ) + } + + cancelAuthTimeout() + authTimeoutJob = viewModelScope.launch { + delay(AUTH_TIMEOUT_MS) + if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) { + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "连接超时,请重试" + ) + } + addSystemMessage("认证超时,请检查网络后重试") + socketClient.close(1000, "auth_timeout") + } + } + + runCatching { + sendAuth(hello.authChallenge) + }.onSuccess { + addSystemMessage("已发送认证请求") + }.onFailure { error -> + cancelAuthTimeout() + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "认证失败" + ) + } + addSystemMessage("认证发送失败:${error.message ?: "unknown"}") + socketClient.close(1000, "auth_failed") + } + } + + private suspend fun sendAuth(challenge: String) { + val id = ensureIdentity() + val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() } + if (displayName != _uiState.value.displayName) { + _uiState.update { it.copy(displayName = displayName) } + preferencesRepository.setDisplayName(displayName) + } + + val timestamp = cryptoManager.unixSecondsNow() + val nonce = cryptoManager.createNonce() + val signingInput = listOf( + "publickey", + displayName, + id.publicKeyBase64, + challenge, + timestamp.toString(), + nonce + ).joinToString("\n") + + val signature = withContext(Dispatchers.Default) { + cryptoManager.signText(id.privateKey, signingInput) + } + + val payload = AuthPayloadDto( + publicKey = id.publicKeyBase64, + challenge = challenge, + timestamp = timestamp, + nonce = nonce, + signature = signature + ) + val envelope = EnvelopeDto( + type = "publickey", + key = displayName, + data = json.encodeToJsonElement(payload) + ) + + val plain = json.encodeToString(envelope) + val cipher = withContext(Dispatchers.Default) { + cryptoManager.encryptChunked(serverPublicKey, plain) + } + check(socketClient.send(cipher)) { "连接不可用" } + } + + private fun handleSecureMessage(message: EnvelopeDto) { + when (message.type) { + "auth_ok" -> { + cancelAuthTimeout() + cancelReconnect() + reconnectAttempt = 0 + _uiState.update { + it.copy( + status = ConnectionStatus.READY, + statusHint = "已连接,可以开始聊天" + ) + } + addSystemMessage("连接准备完成") + } + + "broadcast" -> { + val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户" + addIncomingMessage( + sender = sender, + subtitle = "", + content = message.data.asPayloadText(), + channel = MessageChannel.BROADCAST + ) + } + + "forward" -> { + val sourceKey = message.key.orEmpty() + addIncomingMessage( + sender = "私聊消息", + subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(), + content = message.data.asPayloadText(), + channel = MessageChannel.PRIVATE + ) + } + + else -> addSystemMessage("收到未识别消息类型:${message.type}") + } + } + + private fun handleSocketClosed(code: Int, reason: String) { + cancelHelloTimeout() + cancelAuthTimeout() + + if (manualClose) { + return + } + if (reconnectJob?.isActive == true) { + return + } + + val currentStatus = _uiState.value.status + + if (currentStatus == ConnectionStatus.READY) { + addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") + scheduleReconnect("连接已中断") + return + } + + val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY + + if (allowFallback) { + val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl) + if (fallbackUrl.isNotBlank()) { + fallbackTried = true + connectedUrl = fallbackUrl + _uiState.update { + it.copy( + status = ConnectionStatus.CONNECTING, + statusHint = "正在自动重试连接...", + serverUrl = fallbackUrl + ) + } + addSystemMessage("连接方式切换中,正在重试") + socketClient.connect(fallbackUrl, socketListener) + return + } + } + + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "连接已中断,请检查网络或服务器地址" + ) + } + addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}") + } + + private fun addSystemMessage(content: String) { + val message = UiMessage( + role = MessageRole.SYSTEM, + sender = "系统", + subtitle = "", + content = content, + channel = MessageChannel.BROADCAST + ) + appendMessage(message) + scheduleSystemMessageExpiry(message.id) + } + + private fun addIncomingMessage( + sender: String, + subtitle: String, + content: String, + channel: MessageChannel + ) { + appendMessage( + UiMessage( + role = MessageRole.INCOMING, + sender = sender, + subtitle = subtitle, + content = content, + channel = channel + ) + ) + } + + private fun addOutgoingMessage( + content: String, + subtitle: String, + channel: MessageChannel + ) { + appendMessage( + UiMessage( + role = MessageRole.OUTGOING, + sender = "我", + subtitle = subtitle, + content = content, + channel = channel + ) + ) + } + + private fun appendMessage(message: UiMessage) { + _uiState.update { current -> + val next = (current.messages + message).takeLast(MAX_MESSAGES) + val aliveIds = next.asSequence().map { it.id }.toSet() + val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds } + removedIds.forEach { id -> + systemMessageExpiryJobs.remove(id)?.cancel() + } + current.copy(messages = next) + } + } + + private fun cancelAuthTimeout() { + authTimeoutJob?.cancel() + authTimeoutJob = null + } + + private fun scheduleReconnect(reason: String) { + if (manualClose) return + if (reconnectJob?.isActive == true) return + if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "重连失败,请手动重试" + ) + } + addSystemMessage("自动重连已停止:超过最大重试次数") + return + } + + reconnectAttempt += 1 + val delaySeconds = minOf(30, 1 shl (reconnectAttempt - 1)) + val total = MAX_RECONNECT_ATTEMPTS + addSystemMessage("$reason,${delaySeconds}s 后自动重连($reconnectAttempt/$total)") + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "${delaySeconds}s 后自动重连($reconnectAttempt/$total)" + ) + } + + reconnectJob = viewModelScope.launch { + delay(delaySeconds * 1000L) + if (manualClose) return@launch + + val target = ServerUrlFormatter.normalize(connectedUrl).ifBlank { + ServerUrlFormatter.normalize(_uiState.value.serverUrl) + } + if (target.isBlank()) { + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "重连失败:服务器地址无效" + ) + } + return@launch + } + + fallbackTried = false + serverPublicKey = "" + connectedUrl = target + cancelHelloTimeout() + cancelAuthTimeout() + _uiState.update { + it.copy( + status = ConnectionStatus.CONNECTING, + statusHint = "正在自动重连..." + ) + } + socketClient.connect(target, socketListener) + } + } + + private fun cancelReconnect() { + reconnectJob?.cancel() + reconnectJob = null + } + + private fun scheduleSystemMessageExpiry(messageId: String) { + systemMessageExpiryJobs.remove(messageId)?.cancel() + systemMessageExpiryJobs[messageId] = viewModelScope.launch { + delay(SYSTEM_MESSAGE_TTL_MS) + _uiState.update { current -> + val filtered = current.messages.filterNot { it.id == messageId } + current.copy(messages = filtered) + } + systemMessageExpiryJobs.remove(messageId) + } + } + + private fun cancelSystemMessageExpiryJobs() { + systemMessageExpiryJobs.values.forEach { it.cancel() } + systemMessageExpiryJobs.clear() + } + + private fun startHelloTimeout() { + cancelHelloTimeout() + helloTimeoutJob = viewModelScope.launch { + delay(HELLO_TIMEOUT_MS) + if (_uiState.value.status == ConnectionStatus.HANDSHAKING) { + val currentUrl = connectedUrl.ifBlank { "unknown" } + _uiState.update { + it.copy( + status = ConnectionStatus.ERROR, + statusHint = "握手超时,请检查地址路径与反向代理" + ) + } + addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)") + socketClient.close(1000, "hello_timeout") + } + } + } + + private fun cancelHelloTimeout() { + helloTimeoutJob?.cancel() + helloTimeoutJob = null + } + + private fun summarizeKey(key: String): String { + if (key.length <= 16) return key + return "${key.take(8)}...${key.takeLast(8)}" + } + + private fun createGuestName(): String { + val rand = (100000..999999).random() + return "guest-$rand" + } + + private fun extractJsonCandidate(rawText: String): String { + val trimmed = rawText.trim() + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed + } + + val start = rawText.indexOf('{') + val end = rawText.lastIndexOf('}') + return if (start in 0 until end) { + rawText.substring(start, end + 1) + } else { + rawText + } + } + + override fun onCleared() { + super.onCleared() + cancelSystemMessageExpiryJobs() + cancelReconnect() + cancelHelloTimeout() + cancelAuthTimeout() + socketClient.shutdown() + } + + private companion object { + const val HELLO_TIMEOUT_MS = 12_000L + const val AUTH_TIMEOUT_MS = 20_000L + const val MAX_MESSAGES = 500 + const val MAX_RECONNECT_ATTEMPTS = 5 + const val SYSTEM_MESSAGE_TTL_MS = 1_000L + } +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt new file mode 100644 index 0000000..0a1e4b6 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt @@ -0,0 +1,10 @@ +package com.onlinemsg.client.ui.theme + +import androidx.compose.ui.graphics.Color + +val Primary = Color(0xFF0C6D62) +val OnPrimary = Color(0xFFFFFFFF) +val Secondary = Color(0xFF4A635F) +val Surface = Color(0xFFF7FAF8) +val SurfaceVariant = Color(0xFFDCE8E4) +val Error = Color(0xFFB3261E) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt new file mode 100644 index 0000000..df0d31d --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt @@ -0,0 +1,49 @@ +package com.onlinemsg.client.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme( + primary = Primary, + onPrimary = OnPrimary, + secondary = Secondary, + surface = Surface, + surfaceVariant = SurfaceVariant, + error = Error +) + +private val DarkColors = darkColorScheme( + primary = Primary.copy(alpha = 0.9f), + secondary = Secondary.copy(alpha = 0.9f), + error = Error +) + +@Composable +fun OnlineMsgTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt new file mode 100644 index 0000000..cc0f902 --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.onlinemsg.client.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/android-client/app/src/main/res/values/strings.xml b/android-client/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..128d397 --- /dev/null +++ b/android-client/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + OnlineMsg + diff --git a/android-client/app/src/main/res/values/themes.xml b/android-client/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8b83379 --- /dev/null +++ b/android-client/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/android-client/app/src/main/res/xml/backup_rules.xml b/android-client/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..1b0854f --- /dev/null +++ b/android-client/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,2 @@ + + diff --git a/android-client/app/src/main/res/xml/data_extraction_rules.xml b/android-client/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..da8aa42 --- /dev/null +++ b/android-client/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-client/build.gradle.kts b/android-client/build.gradle.kts new file mode 100644 index 0000000..dedf0a4 --- /dev/null +++ b/android-client/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false +} diff --git a/android-client/gradle.properties b/android-client/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/android-client/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android-client/gradle/wrapper/gradle-wrapper.jar b/android-client/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/android-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-client/gradle/wrapper/gradle-wrapper.properties b/android-client/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/android-client/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-client/gradlew b/android-client/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/android-client/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android-client/gradlew.bat b/android-client/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/android-client/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-client/settings.gradle.kts b/android-client/settings.gradle.kts new file mode 100644 index 0000000..6e7ff22 --- /dev/null +++ b/android-client/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "OnlineMsgAndroidClient" +include(":app") diff --git a/deploy/deploy_test_ws.sh b/deploy/deploy_test_ws.sh new file mode 100755 index 0000000..ffaa9c6 --- /dev/null +++ b/deploy/deploy_test_ws.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# One-click deployment for test environments (WS mode, no forced WSS). +# +# Usage: +# bash deploy/deploy_test_ws.sh +# +# Optional overrides: +# CONTAINER_NAME=onlinemsgserver IMAGE_NAME=onlinemsgserver:latest HOST_PORT=13173 bash deploy/deploy_test_ws.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +CONTAINER_NAME="${CONTAINER_NAME:-onlinemsgserver}" +IMAGE_NAME="${IMAGE_NAME:-onlinemsgserver:latest}" +HOST_PORT="${HOST_PORT:-13173}" + +for cmd in openssl docker ipconfig route awk base64 tr; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Missing required command: ${cmd}" + exit 1 + fi +done + +DEFAULT_IFACE="$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}')" +LAN_IP="" +if [ -n "${DEFAULT_IFACE}" ]; then + LAN_IP="$(ipconfig getifaddr "${DEFAULT_IFACE}" 2>/dev/null || true)" +fi + +if [ -z "${LAN_IP}" ]; then + LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || true)" +fi +if [ -z "${LAN_IP}" ]; then + LAN_IP="$(ipconfig getifaddr en1 2>/dev/null || true)" +fi +if [ -z "${LAN_IP}" ]; then + LAN_IP="127.0.0.1" +fi + +mkdir -p "${ROOT_DIR}/deploy/keys" + +# Generate service RSA key only if missing. +if [ ! -f "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64" ]; then + echo "Generating service RSA key..." + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${ROOT_DIR}/deploy/keys/server_rsa.pem" + openssl pkcs8 -topk8 -inform PEM \ + -in "${ROOT_DIR}/deploy/keys/server_rsa.pem" \ + -outform DER -nocrypt \ + -out "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der" + base64 < "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der" | tr -d '\n' > "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64" +fi + +echo "Building image: ${IMAGE_NAME}" +docker build -t "${IMAGE_NAME}" "${ROOT_DIR}" + +echo "Restarting container: ${CONTAINER_NAME}" +docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \ + -p "${HOST_PORT}:13173" \ + -v "${ROOT_DIR}/deploy/keys:/app/keys:ro" \ + -e REQUIRE_WSS=false \ + -e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \ + "${IMAGE_NAME}" + +echo "Container logs (tail 30):" +docker logs --tail 30 "${CONTAINER_NAME}" + +echo +echo "Done." +echo "Frontend URL (LAN): ws://${LAN_IP}:${HOST_PORT}/" +echo "Frontend URL (local): ws://localhost:${HOST_PORT}/" diff --git a/deploy/prepare_prod_release.sh b/deploy/prepare_prod_release.sh new file mode 100755 index 0000000..fe63e3e --- /dev/null +++ b/deploy/prepare_prod_release.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepare production deployment artifacts: +# 1) Prepare TLS certificate (import existing PEM cert/key, or optional self-signed fallback) +# 2) Prepare server RSA key (for message protocol) +# 3) Build production Docker image +# 4) Export image tar + env template for deployment +# +# Usage examples: +# # Recommended: use real certificate files +# DOMAIN=chat.example.com \ +# TLS_CERT_PEM=/path/fullchain.pem \ +# TLS_KEY_PEM=/path/privkey.pem \ +# TLS_CHAIN_PEM=/path/chain.pem \ +# CERT_PASSWORD='change-me' \ +# bash deploy/prepare_prod_release.sh +# +# # Fallback (not for internet production): generate self-signed cert +# DOMAIN=chat.example.com \ +# SAN_LIST='DNS:www.chat.example.com,IP:10.0.0.8' \ +# GENERATE_SELF_SIGNED=true \ +# CERT_PASSWORD='change-me' \ +# bash deploy/prepare_prod_release.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +DOMAIN="${DOMAIN:-}" +SAN_LIST="${SAN_LIST:-}" +GENERATE_SELF_SIGNED="${GENERATE_SELF_SIGNED:-false}" +CERT_DAYS="${CERT_DAYS:-365}" + +TLS_CERT_PEM="${TLS_CERT_PEM:-}" +TLS_KEY_PEM="${TLS_KEY_PEM:-}" +TLS_CHAIN_PEM="${TLS_CHAIN_PEM:-}" + +CERT_PASSWORD="${CERT_PASSWORD:-changeit}" +IMAGE_NAME="${IMAGE_NAME:-onlinemsgserver}" +IMAGE_TAG="${IMAGE_TAG:-prod}" +IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}" +EXPORT_IMAGE_TAR="${EXPORT_IMAGE_TAR:-true}" + +OUTPUT_DIR="${OUTPUT_DIR:-${ROOT_DIR}/deploy/output/prod}" +CERT_DIR="${ROOT_DIR}/deploy/certs" +KEY_DIR="${ROOT_DIR}/deploy/keys" + +for cmd in openssl docker base64 tr; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Missing required command: ${cmd}" + exit 1 + fi +done + +mkdir -p "${OUTPUT_DIR}" "${CERT_DIR}" "${KEY_DIR}" + +if [ -z "${DOMAIN}" ]; then + echo "DOMAIN is required. Example: DOMAIN=chat.example.com" + exit 1 +fi + +echo "Preparing production artifacts for domain: ${DOMAIN}" + +# 1) Prepare service RSA key for protocol +if [ ! -f "${KEY_DIR}/server_rsa_pkcs8.b64" ]; then + echo "Generating protocol RSA key..." + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${KEY_DIR}/server_rsa.pem" + openssl pkcs8 -topk8 -inform PEM \ + -in "${KEY_DIR}/server_rsa.pem" \ + -outform DER -nocrypt \ + -out "${KEY_DIR}/server_rsa_pkcs8.der" + base64 < "${KEY_DIR}/server_rsa_pkcs8.der" | tr -d '\n' > "${KEY_DIR}/server_rsa_pkcs8.b64" +else + echo "Protocol RSA key already exists, reusing: ${KEY_DIR}/server_rsa_pkcs8.b64" +fi + +# 2) Prepare TLS material +TMP_CERT="${OUTPUT_DIR}/tls.crt" +TMP_KEY="${OUTPUT_DIR}/tls.key" +TMP_CHAIN="${OUTPUT_DIR}/chain.crt" +TMP_FULLCHAIN="${OUTPUT_DIR}/fullchain.crt" + +if [ -n "${TLS_CERT_PEM}" ] || [ -n "${TLS_KEY_PEM}" ]; then + if [ -z "${TLS_CERT_PEM}" ] || [ -z "${TLS_KEY_PEM}" ]; then + echo "TLS_CERT_PEM and TLS_KEY_PEM must be set together." + exit 1 + fi + if [ ! -f "${TLS_CERT_PEM}" ] || [ ! -f "${TLS_KEY_PEM}" ]; then + echo "TLS cert/key file not found." + exit 1 + fi + + echo "Using provided TLS certificate files..." + cp "${TLS_CERT_PEM}" "${TMP_CERT}" + cp "${TLS_KEY_PEM}" "${TMP_KEY}" + + if [ -n "${TLS_CHAIN_PEM}" ]; then + if [ ! -f "${TLS_CHAIN_PEM}" ]; then + echo "TLS_CHAIN_PEM file not found." + exit 1 + fi + cp "${TLS_CHAIN_PEM}" "${TMP_CHAIN}" + cat "${TMP_CERT}" "${TMP_CHAIN}" > "${TMP_FULLCHAIN}" + else + cp "${TMP_CERT}" "${TMP_FULLCHAIN}" + fi +else + if [ "${GENERATE_SELF_SIGNED}" != "true" ]; then + echo "No TLS_CERT_PEM/TLS_KEY_PEM provided." + echo "Provide real cert files, or set GENERATE_SELF_SIGNED=true as fallback." + exit 1 + fi + + echo "WARNING: generating self-signed TLS certificate (not recommended for internet production)." + SAN_VALUE="DNS:${DOMAIN}" + if [ -n "${SAN_LIST}" ]; then + SAN_VALUE="${SAN_VALUE},${SAN_LIST}" + fi + + openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days "${CERT_DAYS}" \ + -subj "/CN=${DOMAIN}" \ + -addext "subjectAltName=${SAN_VALUE}" \ + -keyout "${TMP_KEY}" \ + -out "${TMP_CERT}" + cp "${TMP_CERT}" "${TMP_FULLCHAIN}" +fi + +echo "Building PFX for server runtime..." +openssl pkcs12 -export \ + -inkey "${TMP_KEY}" \ + -in "${TMP_FULLCHAIN}" \ + -out "${CERT_DIR}/server.pfx" \ + -passout "pass:${CERT_PASSWORD}" + +cp "${TMP_CERT}" "${CERT_DIR}/tls.crt" +cp "${TMP_KEY}" "${CERT_DIR}/tls.key" + +# 3) Build production image +echo "Building image: ${IMAGE_REF}" +docker build -t "${IMAGE_REF}" "${ROOT_DIR}" + +# 4) Export artifacts +if [ "${EXPORT_IMAGE_TAR}" = "true" ]; then + TAR_NAME="${IMAGE_NAME//\//_}_${IMAGE_TAG}.tar" + echo "Exporting image tar: ${OUTPUT_DIR}/${TAR_NAME}" + docker save "${IMAGE_REF}" -o "${OUTPUT_DIR}/${TAR_NAME}" +fi + +cat > "${OUTPUT_DIR}/prod.env" < "${OUTPUT_DIR}/run_prod_example.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +# Example production run command: +# 1) copy deploy/certs + deploy/keys to deployment host +# 2) import image tar: docker load -i onlinemsgserver_prod.tar +# 3) run with env file: +# +# docker run -d --name onlinemsgserver --restart unless-stopped \ +# -p 13173:13173 \ +# --env-file /path/prod.env \ +# -v /path/certs:/app/certs:ro \ +# -v /path/keys:/app/keys:ro \ +# onlinemsgserver:prod +EOF +chmod +x "${OUTPUT_DIR}/run_prod_example.sh" + +echo +echo "Done." +echo "Artifacts:" +echo "- TLS runtime cert: ${CERT_DIR}/server.pfx" +echo "- TLS PEM cert/key: ${CERT_DIR}/tls.crt , ${CERT_DIR}/tls.key" +echo "- Protocol RSA key: ${KEY_DIR}/server_rsa_pkcs8.b64" +echo "- Deployment env: ${OUTPUT_DIR}/prod.env" +if [ "${EXPORT_IMAGE_TAR}" = "true" ]; then + echo "- Image tar: ${OUTPUT_DIR}/${IMAGE_NAME//\//_}_${IMAGE_TAG}.tar" +fi diff --git a/deploy/redeploy_with_lan_cert.sh b/deploy/redeploy_with_lan_cert.sh new file mode 100755 index 0000000..9e5c71d --- /dev/null +++ b/deploy/redeploy_with_lan_cert.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# bash deploy/redeploy_with_lan_cert.sh +# +# Optional overrides: +# CONTAINER_NAME=onlinemsgserver IMAGE_NAME=onlinemsgserver:latest CERT_PASSWORD=changeit bash deploy/redeploy_with_lan_cert.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +CONTAINER_NAME="${CONTAINER_NAME:-onlinemsgserver}" +IMAGE_NAME="${IMAGE_NAME:-onlinemsgserver:latest}" +CERT_PASSWORD="${CERT_PASSWORD:-changeit}" + +for cmd in openssl docker ipconfig route awk base64 tr; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Missing required command: ${cmd}" + exit 1 + fi +done + +DEFAULT_IFACE="$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}')" +LAN_IP="" +if [ -n "${DEFAULT_IFACE}" ]; then + LAN_IP="$(ipconfig getifaddr "${DEFAULT_IFACE}" 2>/dev/null || true)" +fi + +if [ -z "${LAN_IP}" ]; then + LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || true)" +fi +if [ -z "${LAN_IP}" ]; then + LAN_IP="$(ipconfig getifaddr en1 2>/dev/null || true)" +fi + +if [ -z "${LAN_IP}" ]; then + echo "Failed to detect LAN IP from default interface/en0/en1." + exit 1 +fi + +echo "LAN IP: ${LAN_IP}" + +mkdir -p "${ROOT_DIR}/deploy/certs" "${ROOT_DIR}/deploy/keys" + +# Generate service RSA key only if missing. +if [ ! -f "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64" ]; then + echo "Generating service RSA key..." + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${ROOT_DIR}/deploy/keys/server_rsa.pem" + openssl pkcs8 -topk8 -inform PEM \ + -in "${ROOT_DIR}/deploy/keys/server_rsa.pem" \ + -outform DER -nocrypt \ + -out "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der" + base64 < "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der" | tr -d '\n' > "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64" +fi + +echo "Reissuing TLS certificate with LAN SAN..." +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 365 \ + -subj "/CN=${LAN_IP}" \ + -addext "subjectAltName=IP:${LAN_IP},IP:127.0.0.1,DNS:localhost" \ + -keyout "${ROOT_DIR}/deploy/certs/tls.key" \ + -out "${ROOT_DIR}/deploy/certs/tls.crt" + +openssl pkcs12 -export \ + -inkey "${ROOT_DIR}/deploy/certs/tls.key" \ + -in "${ROOT_DIR}/deploy/certs/tls.crt" \ + -out "${ROOT_DIR}/deploy/certs/server.pfx" \ + -passout "pass:${CERT_PASSWORD}" + +echo "Rebuilding image: ${IMAGE_NAME}" +docker build -t "${IMAGE_NAME}" "${ROOT_DIR}" + +echo "Restarting container: ${CONTAINER_NAME}" +docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \ + -p 13173:13173 \ + -v "${ROOT_DIR}/deploy/certs:/app/certs:ro" \ + -v "${ROOT_DIR}/deploy/keys:/app/keys:ro" \ + -e REQUIRE_WSS=true \ + -e TLS_CERT_PATH=/app/certs/server.pfx \ + -e TLS_CERT_PASSWORD="${CERT_PASSWORD}" \ + -e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \ + "${IMAGE_NAME}" + +echo "Container logs (tail 30):" +docker logs --tail 30 "${CONTAINER_NAME}" + +echo +echo "Done." +echo "Use this URL in frontend: wss://${LAN_IP}:13173/" +echo "If using self-signed cert, trust deploy/certs/tls.crt on client devices." diff --git a/web-client/README.md b/web-client/README.md new file mode 100644 index 0000000..5d6bbdf --- /dev/null +++ b/web-client/README.md @@ -0,0 +1,47 @@ +# OnlineMsg Web Client + +React 前端客户端,适配当前仓库的加密消息协议,默认隐藏连接细节,仅保留聊天交互。 + +## 开发运行 + +```bash +cd web-client +npm install +npm run dev +``` + +默认地址:`http://localhost:5173` + +## 生产构建 + +```bash +cd web-client +npm install +npm run build +npm run preview +``` + +构建产物输出目录:`web-client/dist` + +## 使用说明 + +- 打开页面后点击“连接” +- 默认会自动使用当前主机名拼接: + - `http` 页面下:`ws://:13173/` + - `https` 页面下:`wss://:13173/` +- 若你手动输入 `ws://`,前端会自动尝试升级到 `wss://` 一次 +- 如需手动指定服务器地址,在“高级连接设置”中填写,例如: + - `wss://example.com:13173/` + - `ws://127.0.0.1:13173/`(仅本地调试) +- “目标公钥”留空为广播,填写后为私聊转发 +- 用户名会自动保存在本地,刷新后继续使用 +- 客户端私钥会保存在本地浏览器(用于持续身份),刷新后不会重复生成 + +## 移动端注意事项 + +- 客户端已支持两套加密实现: + - 优先 `WebCrypto`(性能更好) + - 退化到纯 JS `node-forge`(适配部分 `http` 局域网场景) +- 在纯 JS 加密模式下,首次连接可能需要几秒生成密钥;客户端会复用本地缓存密钥以减少后续等待。 +- 若设备浏览器过旧,仍可能无法完成加密初始化,此时会在页面提示具体原因。 +- 生产环境仍建议使用 `https/wss`。 diff --git a/web-client/index.html b/web-client/index.html new file mode 100644 index 0000000..4a70362 --- /dev/null +++ b/web-client/index.html @@ -0,0 +1,15 @@ + + + + + + OnlineMsg Chat + + +
+ + + diff --git a/web-client/package-lock.json b/web-client/package-lock.json new file mode 100644 index 0000000..bd4067a --- /dev/null +++ b/web-client/package-lock.json @@ -0,0 +1,1684 @@ +{ + "name": "online-msg-web-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "online-msg-web-client", + "version": "0.1.0", + "dependencies": { + "node-forge": "^1.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.2", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web-client/package.json b/web-client/package.json new file mode 100644 index 0000000..b803395 --- /dev/null +++ b/web-client/package.json @@ -0,0 +1,20 @@ +{ + "name": "online-msg-web-client", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "node-forge": "^1.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.2", + "vite": "^5.4.10" + } +} diff --git a/web-client/src/App.jsx b/web-client/src/App.jsx new file mode 100644 index 0000000..a6f627b --- /dev/null +++ b/web-client/src/App.jsx @@ -0,0 +1,1136 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + canInitializeCrypto, + createNonce, + generateClientIdentity, + rsaDecryptChunked, + rsaEncryptChunked, + signText, + unixSecondsNow +} from "./crypto"; + +const STATUS_TEXT = { + idle: "未连接", + connecting: "连接中", + handshaking: "连接中", + authenticating: "连接中", + ready: "已连接", + error: "异常断开" +}; +const STORAGE_DISPLAY_NAME_KEY = "oms_display_name"; +const STORAGE_SERVER_URLS_KEY = "oms_server_urls"; +const STORAGE_CURRENT_SERVER_URL_KEY = "oms_current_server_url"; +const MAX_SERVER_URLS = 8; +const CHANNEL_BROADCAST = "broadcast"; +const CHANNEL_PRIVATE = "private"; + +function isLikelyLocalHost(host) { + const value = (host || "").toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function getDefaultServerUrl() { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const host = window.location.hostname || "localhost"; + if (protocol === "wss" && !isLikelyLocalHost(host)) { + return `${protocol}://${host}/msgws/`; + } + return `${protocol}://${host}:13173/`; +} + +function normalizeServerUrl(input) { + let value = input.trim(); + if (!value) return ""; + + if (!/^[a-z]+:\/\//i.test(value)) { + const preferred = window.location.protocol === "https:" ? "wss" : "ws"; + value = `${preferred}://${value}`; + } + + if (value.startsWith("http://")) { + value = `ws://${value.slice("http://".length)}`; + } else if (value.startsWith("https://")) { + value = `wss://${value.slice("https://".length)}`; + } + + if (!value.endsWith("/")) { + value += "/"; + } + return value; +} + +function dedupeServerUrls(urls) { + const result = []; + const seen = new Set(); + for (const item of urls) { + const normalized = normalizeServerUrl(String(item || "")); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function getInitialServerUrls() { + const fallback = [getDefaultServerUrl()]; + try { + const raw = globalThis.localStorage?.getItem(STORAGE_SERVER_URLS_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return fallback; + const normalized = dedupeServerUrls(parsed); + return normalized.length > 0 ? normalized : fallback; + } catch { + return fallback; + } +} + +function getInitialServerUrl(serverUrls) { + try { + const stored = globalThis.localStorage?.getItem(STORAGE_CURRENT_SERVER_URL_KEY); + const normalized = normalizeServerUrl(stored || ""); + if (normalized) { + return normalized; + } + } catch { + // ignore + } + return serverUrls[0] || getDefaultServerUrl(); +} + +function appendServerUrl(list, urlText) { + const normalized = normalizeServerUrl(urlText); + if (!normalized) return list; + const next = [normalized, ...list.filter((item) => item !== normalized)]; + return next.slice(0, MAX_SERVER_URLS); +} + +function toggleWsProtocol(urlText) { + try { + const url = new URL(urlText); + if (url.protocol === "ws:") { + url.protocol = "wss:"; + return url.toString(); + } + } catch { + return ""; + } + return ""; +} + +function createGuestName() { + const rand = Math.random().toString(36).slice(2, 8); + return `guest-${rand}`; +} + +function getInitialDisplayName() { + try { + const stored = globalThis.localStorage?.getItem(STORAGE_DISPLAY_NAME_KEY)?.trim(); + if (stored) { + return stored.slice(0, 64); + } + } catch { + // ignore + } + return createGuestName(); +} + +function formatTime(ts) { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit" + }).format(ts); +} + +function safeJsonParse(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function summarizeKey(key = "") { + if (!key) return ""; + if (key.length <= 16) return key; + return `${key.slice(0, 8)}...${key.slice(-8)}`; +} + +function createLocalId() { + const c = globalThis.crypto; + if (c?.randomUUID) { + return c.randomUUID(); + } + if (c?.getRandomValues) { + const buf = new Uint8Array(12); + c.getRandomValues(buf); + return Array.from(buf) + .map((v) => v.toString(16).padStart(2, "0")) + .join(""); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function getCryptoIssueMessage() { + if (!canInitializeCrypto()) { + return "当前浏览器不支持必要的加密能力,请升级浏览器后重试。"; + } + return ""; +} + +function inferMessageChannel(item) { + if (item?.channel === CHANNEL_PRIVATE || item?.channel === CHANNEL_BROADCAST) { + return item.channel; + } + if (item?.sender === "私聊消息" || String(item?.subtitle || "").includes("私聊")) { + return CHANNEL_PRIVATE; + } + return CHANNEL_BROADCAST; +} + +export default function App() { + const initialServerUrls = getInitialServerUrls(); + const AUTO_SCROLL_THRESHOLD = 24; + + const wsRef = useRef(null); + const identityRef = useRef(null); + const identityPromiseRef = useRef(null); + const serverPublicKeyRef = useRef(""); + const manualCloseRef = useRef(false); + const fallbackTriedRef = useRef(false); + const statusRef = useRef("idle"); + const authTimeoutRef = useRef(0); + const copyNoticeTimerRef = useRef(0); + const messageCopyTimerRef = useRef(0); + const draftComposingRef = useRef(false); + const targetComposingRef = useRef(false); + const messageListRef = useRef(null); + const stickToBottomRef = useRef(true); + + const [status, setStatus] = useState("idle"); + const [statusHint, setStatusHint] = useState("点击连接开始聊天"); + const [serverUrls, setServerUrls] = useState(initialServerUrls); + const [serverUrl, setServerUrl] = useState(() => getInitialServerUrl(initialServerUrls)); + const [displayName, setDisplayName] = useState(getInitialDisplayName); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [targetKey, setTargetKey] = useState(""); + const [directMode, setDirectMode] = useState(false); + const [draft, setDraft] = useState(""); + const [messages, setMessages] = useState([]); + const [showSystemMessages, setShowSystemMessages] = useState(false); + const [sending, setSending] = useState(false); + const [certFingerprint, setCertFingerprint] = useState(""); + const [myPublicKey, setMyPublicKey] = useState(""); + const [publicKeyBusy, setPublicKeyBusy] = useState(false); + const [copyNotice, setCopyNotice] = useState(""); + const [copiedMessageId, setCopiedMessageId] = useState(""); + const [activeMobileTab, setActiveMobileTab] = useState("chat"); + + const isConnected = status === "ready"; + const canConnect = status === "idle" || status === "error"; + const canDisconnect = status !== "idle" && status !== "error"; + const canSend = isConnected && draft.trim().length > 0 && !sending; + const activeChannel = directMode ? CHANNEL_PRIVATE : CHANNEL_BROADCAST; + const mobileConnectText = useMemo(() => { + if (status === "ready") return "已连接"; + if (status === "error") return "连接失败,点击重试"; + if (status === "idle") return "未连接,点击连接"; + return "连接中"; + }, [status]); + const visibleMessages = useMemo( + () => + messages.filter((item) => { + if (item.role === "system") { + return showSystemMessages; + } + return inferMessageChannel(item) === activeChannel; + }), + [messages, showSystemMessages, activeChannel] + ); + + const statusClass = useMemo(() => { + if (status === "ready") return "ok"; + if (status === "error") return "bad"; + return "loading"; + }, [status]); + + useEffect(() => { + statusRef.current = status; + }, [status]); + + useEffect(() => { + try { + const value = displayName.trim(); + if (value) { + globalThis.localStorage?.setItem(STORAGE_DISPLAY_NAME_KEY, value); + } else { + globalThis.localStorage?.removeItem(STORAGE_DISPLAY_NAME_KEY); + } + } catch { + // ignore storage failures in private mode + } + }, [displayName]); + + useEffect(() => { + try { + globalThis.localStorage?.setItem(STORAGE_SERVER_URLS_KEY, JSON.stringify(serverUrls)); + } catch { + // ignore storage failures + } + }, [serverUrls]); + + useEffect(() => { + try { + const value = serverUrl.trim(); + if (value) { + globalThis.localStorage?.setItem(STORAGE_CURRENT_SERVER_URL_KEY, value); + } + } catch { + // ignore storage failures + } + }, [serverUrl]); + + useEffect(() => { + return () => { + manualCloseRef.current = true; + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = 0; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + if (copyNoticeTimerRef.current) { + clearTimeout(copyNoticeTimerRef.current); + copyNoticeTimerRef.current = 0; + } + if (messageCopyTimerRef.current) { + clearTimeout(messageCopyTimerRef.current); + messageCopyTimerRef.current = 0; + } + }; + }, []); + + useEffect(() => { + const root = document.documentElement; + const vv = globalThis.visualViewport; + + const updateViewportVars = () => { + const top = vv ? Math.max(0, vv.offsetTop) : 0; + const height = vv ? vv.height : globalThis.innerHeight; + root.style.setProperty("--vv-offset-top", `${top}px`); + root.style.setProperty("--vv-height", `${height}px`); + }; + + updateViewportVars(); + + vv?.addEventListener("resize", updateViewportVars); + vv?.addEventListener("scroll", updateViewportVars); + globalThis.addEventListener("resize", updateViewportVars); + + return () => { + vv?.removeEventListener("resize", updateViewportVars); + vv?.removeEventListener("scroll", updateViewportVars); + globalThis.removeEventListener("resize", updateViewportVars); + root.style.removeProperty("--vv-offset-top"); + root.style.removeProperty("--vv-height"); + }; + }, []); + + useEffect(() => { + const list = messageListRef.current; + if (!list) return; + list.scrollTop = list.scrollHeight; + }, []); + + useEffect(() => { + const list = messageListRef.current; + if (!list || !stickToBottomRef.current) return; + list.scrollTop = list.scrollHeight; + }, [visibleMessages.length]); + + function pushSystem(text) { + setMessages((prev) => [ + ...prev, + { + id: createLocalId(), + role: "system", + content: text, + ts: Date.now() + } + ]); + } + + function pushIncoming(sender, text, subtitle = "", channel = CHANNEL_BROADCAST) { + setMessages((prev) => [ + ...prev, + { + id: createLocalId(), + role: "incoming", + sender, + subtitle, + channel, + content: text, + ts: Date.now() + } + ]); + } + + function pushOutgoing(text, subtitle = "", channel = CHANNEL_BROADCAST) { + setMessages((prev) => [ + ...prev, + { + id: createLocalId(), + role: "outgoing", + sender: "我", + subtitle, + channel, + content: text, + ts: Date.now() + } + ]); + } + + async function ensureIdentity() { + if (identityRef.current) { + return identityRef.current; + } + if (identityPromiseRef.current) { + return identityPromiseRef.current; + } + + identityPromiseRef.current = generateClientIdentity() + .then((identity) => { + identityRef.current = identity; + return identity; + }) + .finally(() => { + identityPromiseRef.current = null; + }); + + return identityPromiseRef.current; + } + + async function revealMyPublicKey() { + setPublicKeyBusy(true); + try { + const identity = await ensureIdentity(); + setMyPublicKey(identity.publicKeyBase64); + } catch (error) { + pushSystem(`生成公钥失败:${error?.message || "unknown error"}`); + } finally { + setPublicKeyBusy(false); + } + } + + async function copyMyPublicKey() { + if (!myPublicKey) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(myPublicKey); + } else { + const temp = document.createElement("textarea"); + temp.value = myPublicKey; + temp.setAttribute("readonly", "true"); + temp.style.position = "fixed"; + temp.style.opacity = "0"; + document.body.appendChild(temp); + temp.select(); + document.execCommand("copy"); + document.body.removeChild(temp); + } + setCopyNotice("已复制"); + } catch { + setCopyNotice("复制失败"); + } + + if (copyNoticeTimerRef.current) { + clearTimeout(copyNoticeTimerRef.current); + } + copyNoticeTimerRef.current = window.setTimeout(() => { + setCopyNotice(""); + copyNoticeTimerRef.current = 0; + }, 1800); + } + + async function copyMessageText(messageId, text) { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const temp = document.createElement("textarea"); + temp.value = text; + temp.setAttribute("readonly", "true"); + temp.style.position = "fixed"; + temp.style.opacity = "0"; + document.body.appendChild(temp); + temp.select(); + document.execCommand("copy"); + document.body.removeChild(temp); + } + setCopiedMessageId(messageId); + } catch { + pushSystem("消息复制失败"); + return; + } + + if (messageCopyTimerRef.current) { + clearTimeout(messageCopyTimerRef.current); + } + messageCopyTimerRef.current = window.setTimeout(() => { + setCopiedMessageId(""); + messageCopyTimerRef.current = 0; + }, 1600); + } + + async function connect() { + if (!canConnect) return; + const cryptoIssue = getCryptoIssueMessage(); + if (cryptoIssue) { + setStatus("error"); + setStatusHint(cryptoIssue); + pushSystem(cryptoIssue); + return; + } + + const normalizedUrl = normalizeServerUrl(serverUrl); + if (!normalizedUrl) { + setStatus("error"); + setStatusHint("请填写服务器地址"); + return; + } + + manualCloseRef.current = false; + setStatus("connecting"); + setStatusHint("正在连接服务器..."); + setCertFingerprint(""); + serverPublicKeyRef.current = ""; + fallbackTriedRef.current = false; + + setServerUrl(normalizedUrl); + setServerUrls((prev) => appendServerUrl(prev, normalizedUrl)); + openSocket(normalizedUrl); + } + + function openSocket(urlText) { + let ws; + const allowFallback = !fallbackTriedRef.current; + + try { + ws = new WebSocket(urlText); + } catch (error) { + setStatus("error"); + setStatusHint(`连接失败:${error.message}`); + return; + } + + wsRef.current = ws; + + ws.onopen = async () => { + setStatus("handshaking"); + setStatusHint("已连接,正在准备聊天..."); + pushSystem("连接已建立"); + }; + + ws.onerror = () => { + setStatusHint("连接异常,等待重试或关闭"); + }; + + ws.onclose = (event) => { + wsRef.current = null; + if (manualCloseRef.current) { + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = 0; + } + setStatus("idle"); + setStatusHint("连接已关闭"); + pushSystem("已断开连接"); + return; + } + + if (allowFallback && statusRef.current !== "ready") { + const fallbackUrl = toggleWsProtocol(urlText); + if (fallbackUrl) { + fallbackTriedRef.current = true; + setStatus("connecting"); + setStatusHint("正在自动重试连接..."); + pushSystem("连接方式切换中,正在重试"); + openSocket(fallbackUrl); + return; + } + } + + setStatus("error"); + setStatusHint("连接已中断,请检查网络或服务器地址"); + pushSystem(`连接关闭 (${event.code}):${event.reason || "连接中断"}`); + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = 0; + } + }; + + ws.onmessage = async (event) => { + if (typeof event.data !== "string") return; + await handleMessage(event.data); + }; + } + + function disconnect() { + manualCloseRef.current = true; + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + } + + async function handleMessage(rawText) { + const plain = safeJsonParse(rawText); + if (plain?.type === "publickey" && plain?.data?.publicKey) { + await handleServerHello(plain.data); + return; + } + + const identity = identityRef.current; + if (!identity) return; + + try { + const decoded = await rsaDecryptChunked(identity.decryptPrivateKey, rawText); + const secureMessage = safeJsonParse(decoded); + if (!secureMessage) return; + handleSecureMessage(secureMessage); + } catch { + pushSystem("收到无法解密的消息"); + } + } + + async function handleServerHello(hello) { + if (!hello.publicKey || !hello.authChallenge) { + setStatus("error"); + setStatusHint("握手失败:服务端响应不完整"); + return; + } + + serverPublicKeyRef.current = hello.publicKey; + setCertFingerprint(hello.certFingerprintSha256 || ""); + setStatus("authenticating"); + setStatusHint("正在完成身份验证..."); + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + } + authTimeoutRef.current = window.setTimeout(() => { + if (statusRef.current === "authenticating") { + setStatus("error"); + setStatusHint("连接超时,请重试"); + pushSystem("认证超时,请检查网络后重试"); + disconnect(); + } + }, 20000); + + try { + await sendAuth(hello.authChallenge); + pushSystem("已发送认证请求"); + } catch (error) { + setStatus("error"); + setStatusHint("认证失败"); + pushSystem(`认证发送失败:${error.message}`); + disconnect(); + } + } + + async function sendAuth(challenge) { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error("连接不可用"); + } + + const identity = await ensureIdentity(); + if (!myPublicKey) { + setMyPublicKey(identity.publicKeyBase64); + } + const user = displayName.trim() || createGuestName(); + if (user !== displayName) { + setDisplayName(user); + } + + const timestamp = unixSecondsNow(); + const nonce = createNonce(); + const signInput = ["publickey", user, identity.publicKeyBase64, challenge, timestamp, nonce].join("\n"); + const signature = await signText(identity.signPrivateKey, signInput); + + const envelope = { + type: "publickey", + key: user, + data: { + publicKey: identity.publicKeyBase64, + challenge, + timestamp, + nonce, + signature + } + }; + + const cipher = await rsaEncryptChunked(serverPublicKeyRef.current, JSON.stringify(envelope)); + ws.send(cipher); + } + + function handleSecureMessage(message) { + if (message.type === "auth_ok") { + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = 0; + } + setStatus("ready"); + setStatusHint("已连接,可以开始聊天"); + pushSystem("连接准备完成"); + return; + } + + if (message.type === "broadcast") { + pushIncoming(message.key || "匿名用户", String(message.data ?? ""), "", CHANNEL_BROADCAST); + return; + } + + if (message.type === "forward") { + const sender = "私聊消息"; + pushIncoming(sender, String(message.data ?? ""), "", CHANNEL_PRIVATE); + return; + } + + pushSystem(`收到未识别消息类型:${message.type}`); + } + + async function sendMessage() { + if (!canSend) return; + + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + setStatus("error"); + setStatusHint("连接已断开"); + return; + } + + const identity = identityRef.current; + const serverPublicKey = serverPublicKeyRef.current; + if (!identity || !serverPublicKey) return; + + const text = draft.trim(); + const key = directMode ? targetKey.trim() : ""; + if (directMode && !key) { + setStatusHint("请先填写目标公钥,再发送私聊消息"); + return; + } + const type = key ? "forward" : "broadcast"; + const channel = key ? CHANNEL_PRIVATE : CHANNEL_BROADCAST; + const subtitle = key ? `私聊 ${summarizeKey(key)}` : ""; + + setSending(true); + try { + const timestamp = unixSecondsNow(); + const nonce = createNonce(); + const signInput = [type, key, text, timestamp, nonce].join("\n"); + const signature = await signText(identity.signPrivateKey, signInput); + + const envelope = { + type, + key, + data: { + payload: text, + timestamp, + nonce, + signature + } + }; + + const cipher = await rsaEncryptChunked(serverPublicKey, JSON.stringify(envelope)); + ws.send(cipher); + pushOutgoing(text, subtitle, channel); + setDraft(""); + } catch (error) { + pushSystem(`发送失败:${error.message}`); + } finally { + setSending(false); + } + } + + function onDraftKeyDown(event) { + const isComposing = + draftComposingRef.current || + event.isComposing || + event.nativeEvent?.isComposing || + event.keyCode === 229; + if (isComposing) { + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } + } + + function onTargetKeyDown(event) { + const isComposing = + targetComposingRef.current || + event.isComposing || + event.nativeEvent?.isComposing || + event.keyCode === 229; + if (isComposing) { + event.stopPropagation(); + } + } + + function onMessageListScroll(event) { + const el = event.currentTarget; + const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceToBottom <= AUTO_SCROLL_THRESHOLD; + } + + function saveCurrentServerUrl() { + const normalizedUrl = normalizeServerUrl(serverUrl); + if (!normalizedUrl) { + setStatusHint("请输入有效的服务器地址"); + return; + } + setServerUrl(normalizedUrl); + setServerUrls((prev) => appendServerUrl(prev, normalizedUrl)); + setStatusHint("服务器地址已保存"); + } + + function removeCurrentServerUrl() { + const normalizedCurrent = normalizeServerUrl(serverUrl); + const filtered = serverUrls.filter((item) => item !== normalizedCurrent); + if (filtered.length === 0) { + const fallback = getDefaultServerUrl(); + setServerUrls([fallback]); + setServerUrl(fallback); + setStatusHint("已恢复默认服务器地址"); + return; + } + setServerUrls(filtered); + setServerUrl(filtered[0]); + setStatusHint("已移除当前服务器地址"); + } + + return ( +
+
+
+

OnlineMsg Chat

+
+
+ + {STATUS_TEXT[status]} +
+
+ +
+
+
+
+ OM +
+ 在线会话 +

{statusHint}

+
+
+
+ {canConnect ? ( + + ) : ( + + )} + +
+
+ +
+
+ + + +
+
+ +
+
+ + {directMode ? ( +
+