From 31b76ec69c79d2d251fdce9578fc2a51de8f18ca Mon Sep 17 00:00:00 2001 From: alimu Date: Thu, 5 Mar 2026 20:41:29 +0400 Subject: [PATCH] Initial import --- .gitignore | 8 + Common/BroadcastMessage.cs | 66 + Common/ForwardMessage.cs | 79 + Common/Log.cs | 17 + Common/Message.cs | 73 + Common/MessageConverter.cs | 52 + Common/ProtocolPayloads.cs | 147 ++ Common/PublicKeyMessage.cs | 127 ++ Common/User.cs | 56 + Core/RsaService.cs | 174 ++ Core/SecurityConfig.cs | 93 + Core/SecurityRuntime.cs | 19 + Core/SecurityValidator.cs | 55 + Core/UserService.cs | 233 +++ Core/WsService.cs | 139 ++ Dockerfile | 47 + OnlineMsgServer.csproj | 14 + OnlineMsgServer.sln | 25 + Program.cs | 100 + ReadMe.md | 202 ++ android-client/.gitignore | 5 + android-client/README.md | 54 + android-client/app/.DS_Store | Bin 0 -> 6148 bytes android-client/app/build.gradle.kts | 84 + android-client/app/proguard-rules.pro | 1 + .../app/src/main/AndroidManifest.xml | 25 + .../java/com/onlinemsg/client/MainActivity.kt | 17 + .../client/data/crypto/RsaCryptoManager.kt | 186 ++ .../data/network/OnlineMsgSocketClient.kt | 83 + .../data/preferences/ServerUrlFormatter.kt | 79 + .../preferences/UserPreferencesRepository.kt | 123 ++ .../client/data/protocol/ProtocolModels.kt | 54 + .../com/onlinemsg/client/ui/ChatScreen.kt | 589 ++++++ .../com/onlinemsg/client/ui/ChatUiState.kt | 86 + .../com/onlinemsg/client/ui/ChatViewModel.kt | 838 ++++++++ .../com/onlinemsg/client/ui/theme/Color.kt | 10 + .../com/onlinemsg/client/ui/theme/Theme.kt | 49 + .../com/onlinemsg/client/ui/theme/Type.kt | 5 + .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/themes.xml | 7 + .../app/src/main/res/xml/backup_rules.xml | 2 + .../main/res/xml/data_extraction_rules.xml | 5 + android-client/build.gradle.kts | 5 + android-client/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android-client/gradlew | 248 +++ android-client/gradlew.bat | 93 + android-client/settings.gradle.kts | 18 + deploy/deploy_test_ws.sh | 73 + deploy/prepare_prod_release.sh | 191 ++ deploy/redeploy_with_lan_cert.sh | 91 + web-client/README.md | 47 + web-client/index.html | 15 + web-client/package-lock.json | 1684 +++++++++++++++++ web-client/package.json | 20 + web-client/src/App.jsx | 1136 +++++++++++ web-client/src/crypto.js | 348 ++++ web-client/src/main.jsx | 10 + web-client/src/styles.css | 915 +++++++++ web-client/vite.config.js | 10 + 61 files changed, 8946 insertions(+) create mode 100644 .gitignore create mode 100644 Common/BroadcastMessage.cs create mode 100644 Common/ForwardMessage.cs create mode 100644 Common/Log.cs create mode 100644 Common/Message.cs create mode 100644 Common/MessageConverter.cs create mode 100644 Common/ProtocolPayloads.cs create mode 100644 Common/PublicKeyMessage.cs create mode 100644 Common/User.cs create mode 100644 Core/RsaService.cs create mode 100644 Core/SecurityConfig.cs create mode 100644 Core/SecurityRuntime.cs create mode 100644 Core/SecurityValidator.cs create mode 100644 Core/UserService.cs create mode 100644 Core/WsService.cs create mode 100644 Dockerfile create mode 100644 OnlineMsgServer.csproj create mode 100644 OnlineMsgServer.sln create mode 100644 Program.cs create mode 100644 ReadMe.md create mode 100644 android-client/.gitignore create mode 100644 android-client/README.md create mode 100644 android-client/app/.DS_Store create mode 100644 android-client/app/build.gradle.kts create mode 100644 android-client/app/proguard-rules.pro create mode 100644 android-client/app/src/main/AndroidManifest.xml create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/MainActivity.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/crypto/RsaCryptoManager.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/network/OnlineMsgSocketClient.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/preferences/ServerUrlFormatter.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/preferences/UserPreferencesRepository.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Color.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Theme.kt create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/ui/theme/Type.kt create mode 100644 android-client/app/src/main/res/values/strings.xml create mode 100644 android-client/app/src/main/res/values/themes.xml create mode 100644 android-client/app/src/main/res/xml/backup_rules.xml create mode 100644 android-client/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android-client/build.gradle.kts create mode 100644 android-client/gradle.properties create mode 100644 android-client/gradle/wrapper/gradle-wrapper.jar create mode 100644 android-client/gradle/wrapper/gradle-wrapper.properties create mode 100755 android-client/gradlew create mode 100644 android-client/gradlew.bat create mode 100644 android-client/settings.gradle.kts create mode 100755 deploy/deploy_test_ws.sh create mode 100755 deploy/prepare_prod_release.sh create mode 100755 deploy/redeploy_with_lan_cert.sh create mode 100644 web-client/README.md create mode 100644 web-client/index.html create mode 100644 web-client/package-lock.json create mode 100644 web-client/package.json create mode 100644 web-client/src/App.jsx create mode 100644 web-client/src/crypto.js create mode 100644 web-client/src/main.jsx create mode 100644 web-client/src/styles.css create mode 100644 web-client/vite.config.js 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 0000000000000000000000000000000000000000..ca775671c94159d0188964fec2ea94aab0f39fda GIT binary patch literal 6148 zcmeHK%}T>S5Z<+|O({YS3Oz1(E!fr~6)z#y7cim+m70*E!I&*gVh^Q|v%Zi|;`2DO zyMY#iM-e*%yWi~m>}Ed5{xHV4n}>bIY{r-c4UwZ#A!x32?U-OhuI31XMLG*&8B|R3 zH%<8M4HmGRMJ#6P-~SQJ;wa5}y-&VVt2cI=R?})*_ui9Sc$uH&sq4>gadahR9F)2r zTu0Nz#NI!bN#;k%bgmMja0(%JH&GJGg)8$UOjWL@9ahU~P3+Ec+3%0KVlW!6x?*`c z=yk>L_++(eSqFzlXP1-b_$85Vnn(_eE7>zx!aFEy6}@`%B$mk|Sj&tul8_i628aP- zU^^Kw=YiGU&hn{hVt^Rtk>1LQ&>y{Z<~HwF24$G!%?0Pyqpb;}QS^+($afsr>?Vh;s}U V8gUl1t8_rR2q;3RBL;qffiI+CO3VNN literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 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 ? ( +
+