feat: 接入消息加密与实名认证能力

This commit is contained in:
richjohnrambo
2026-09-21 09:53:39 +08:00
parent 841c0c77cd
commit e3b93113f1
33 changed files with 1747 additions and 99 deletions
+10 -1
View File
@@ -5,9 +5,11 @@ go 1.26.0
require (
firebase.google.com/go v3.13.0+incompatible
github.com/AgoraIO/Tools/DynamicKey/AgoraDynamicKey/go/src v0.0.0-20260726132416-da06bfb83556
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4
github.com/alibabacloud-go/green-20220302/v3 v3.5.4
github.com/alibabacloud-go/tea v1.5.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27
@@ -52,7 +54,14 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/openapi-util v0.1.0 // indirect
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1 // indirect
github.com/alibabacloud-go/tea-fileform v1.1.1 // indirect
github.com/alibabacloud-go/tea-oss-sdk v1.1.3 // indirect
github.com/alibabacloud-go/tea-oss-utils v1.1.0 // indirect
github.com/alibabacloud-go/tea-utils v1.3.6 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
+27 -1
View File
@@ -44,12 +44,16 @@ github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do2
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2 h1:y4s0WQ1jrBtOJfXGgsv/83brJvkkHbFdORp0WDyVAuw=
github.com/alibabacloud-go/cloudauth-20190307/v3 v3.9.2/go.mod h1:kD75qqMQyjCiz6lssjRzYGTumcli8STLXQstVe6ytxk=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.0/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10/go.mod h1:26a14FGhZVELuz2cc2AolvW4RHmIO3/HRwsdHhaIPDE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
@@ -64,27 +68,47 @@ github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgT
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ=
github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1 h1:L0TIjr9Qh/SLVc1yPhFkcB9+9SbCNK/jPq4ZKB5zmnc=
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1/go.mod h1:EKxBRDLcMzwl4VLF/1WJwlByZZECJawPXUvinKMsTTs=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.10/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.12/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU=
github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-fileform v1.1.1 h1:1YG6erAP3joQ0XdCXYIotuD7zyOM6qCR49xkp5FZDeU=
github.com/alibabacloud-go/tea-fileform v1.1.1/go.mod h1:ZeCV91o4ISmxidd686f0ebdS5EDHWU+vW+TkjLhrsFE=
github.com/alibabacloud-go/tea-oss-sdk v1.1.3 h1:EhAHI6edMeqgkZEqP7r4nc9iMWAUBKGxJHoBsOSKTtU=
github.com/alibabacloud-go/tea-oss-sdk v1.1.3/go.mod h1:yUnodpR3Bf2rudLE7V/Gft5txjJF30Pk+hH77K/Eab0=
github.com/alibabacloud-go/tea-oss-utils v1.1.0 h1:y65crjjcZ2Pbb6UZtC2deuIZHDVTS3IaDWE7M9nVLRc=
github.com/alibabacloud-go/tea-oss-utils v1.1.0/go.mod h1:PFCF12e9yEKyBUIn7X1IrF/pNjvxgkHy0CgxX4+xRuY=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils v1.3.6 h1:bVjrxHztM8hAs6nOfLWCgxQfAtKb9RgFFMV6J3rdvB4=
github.com/alibabacloud-go/tea-utils v1.3.6/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.1/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.3.10/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
@@ -138,6 +162,7 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -494,6 +519,7 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+6
View File
@@ -721,6 +721,8 @@ CREATE TABLE IF NOT EXISTS `im_group_messages` (
`msg_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等',
`status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked',
`content` json DEFAULT NULL COMMENT '消息正文或 Drafty 内容',
`content_nonce` varbinary(12) DEFAULT NULL COMMENT '每条消息独立随机Nonce',
`key_version` int NOT NULL DEFAULT '0' COMMENT '商户消息DEK版本',
`head` json DEFAULT NULL COMMENT '消息头,包含 @、引用、客户端能力等',
`mentioned_user_ids` json DEFAULT NULL COMMENT '@ 用户列表,便于提醒和检索',
`mention_all` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否 @ 全体',
@@ -1088,6 +1090,8 @@ CREATE TABLE IF NOT EXISTS `im_message_revisions` (
`actor` bigint NOT NULL,
`head` json DEFAULT NULL,
`content` json DEFAULT NULL,
`content_nonce` varbinary(12) DEFAULT NULL,
`key_version` int NOT NULL DEFAULT '0',
`reason` varchar(256) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_message_revisions_version` (`topic`,`seqid`,`version`)
@@ -1108,6 +1112,8 @@ CREATE TABLE IF NOT EXISTS `im_messages` (
`recalled_by` bigint DEFAULT NULL,
`head` json DEFAULT NULL,
`content` json DEFAULT NULL,
`content_nonce` varbinary(12) DEFAULT NULL,
`key_version` int NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`),
UNIQUE KEY `uk_im_messages_topic_seq` (`topic`,`seqid`)
+2
View File
@@ -383,6 +383,7 @@ func main() {
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
friend_request_verification TINYINT NOT NULL DEFAULT 1,
group_invite_verification TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -540,6 +541,7 @@ func main() {
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions",
"ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent",
"ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region",
"ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts",
"ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts",
} {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
+86
View File
@@ -0,0 +1,86 @@
-- 在当前商户业务库增加消息加密字段。
-- 可重复执行,覆盖单聊、消息修订、群聊主表及全部现有群消息月分片。
DELIMITER $$
DROP PROCEDURE IF EXISTS add_message_crypto_column$$
CREATE PROCEDURE add_message_crypto_column(
IN p_table VARCHAR(64),
IN p_column VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table
) AND NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', REPLACE(p_table, '`', '``'), '` ADD COLUMN `',
REPLACE(p_column, '`', '``'), '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DROP PROCEDURE IF EXISTS drop_message_crypto_column$$
CREATE PROCEDURE drop_message_crypto_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64))
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', REPLACE(p_table, '`', '``'), '` DROP COLUMN `',
REPLACE(p_column, '`', '``'), '`');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DROP PROCEDURE IF EXISTS migrate_message_crypto_table$$
CREATE PROCEDURE migrate_message_crypto_table(IN p_table VARCHAR(64))
BEGIN
CALL add_message_crypto_column(p_table, 'content_nonce', 'VARBINARY(12) NULL COMMENT ''每条消息独立随机Nonce'' AFTER `content`');
CALL add_message_crypto_column(p_table, 'key_version', 'INT NOT NULL DEFAULT 0 COMMENT ''商户消息DEK版本'' AFTER `content_nonce`');
CALL drop_message_crypto_column(p_table, 'content_ciphertext');
CALL drop_message_crypto_column(p_table, 'encryption_alg');
CALL drop_message_crypto_column(p_table, 'encrypted');
END$$
DROP PROCEDURE IF EXISTS migrate_message_crypto_schema$$
CREATE PROCEDURE migrate_message_crypto_schema()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE table_name_value VARCHAR(64);
DECLARE shard_cursor CURSOR FOR
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME REGEXP '^im_group_messages_[0-9]{6}$';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
CALL migrate_message_crypto_table('im_messages');
CALL migrate_message_crypto_table('im_message_revisions');
CALL migrate_message_crypto_table('im_group_messages');
OPEN shard_cursor;
shard_loop: LOOP
FETCH shard_cursor INTO table_name_value;
IF done = 1 THEN
LEAVE shard_loop;
END IF;
CALL migrate_message_crypto_table(table_name_value);
END LOOP;
CLOSE shard_cursor;
END$$
CALL migrate_message_crypto_schema()$$
DROP PROCEDURE migrate_message_crypto_schema$$
DROP PROCEDURE migrate_message_crypto_table$$
DROP PROCEDURE drop_message_crypto_column$$
DROP PROCEDURE add_message_crypto_column$$
DELIMITER ;
+48 -2
View File
@@ -90,6 +90,7 @@ func initAdminInternalAPI(raw json.RawMessage) (*adminInternalClient, error) {
}
store.SetAdminInternalConfigProvider(client)
loginIPWhitelistProvider = client
go store.WarmMessageEncryptionKeys()
logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName)
return client, nil
}
@@ -211,6 +212,40 @@ func (c *adminInternalClient) SecurityPolicy(tenantCode string) (*types.Security
return &result, nil
}
func (c *adminInternalClient) MessageKey(tenantCode string, version int) (*types.MessageKey, error) {
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/message-keys/active"
if version > 0 {
path = "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/message-keys/" + strconv.Itoa(version)
}
var result types.MessageKey
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
func (c *adminInternalClient) MessageKeyAck(tenantCode string, version int) error {
if version <= 0 {
return types.ErrMalformed
}
path := "/internal/im-config/tenants/" + url.PathEscape(strings.TrimSpace(tenantCode)) + "/message-keys/ack"
return c.doJSON(http.MethodPost, path, nil, map[string]int{"version": version}, nil)
}
func (c *adminInternalClient) RealNamePolicy(id types.TenantID) (*types.RealNamePolicy, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
var result types.RealNamePolicy
path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/realname-policy"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
result.TenantID = id
result.Normalize()
return &result, nil
}
func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
@@ -267,6 +302,10 @@ func (c *adminInternalClient) LatestAppRelease(platform, channel string) (*types
}
func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
return c.doJSON(http.MethodGet, path, query, nil, out)
}
func (c *adminInternalClient) doJSON(method, path string, query url.Values, payload, out any) error {
if c == nil || c.http == nil {
return types.ErrUnsupported
}
@@ -278,11 +317,18 @@ func (c *adminInternalClient) getJSON(path string, query url.Values, out any) er
if len(query) > 0 {
u.RawQuery = query.Encode()
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
var requestBody []byte
if payload != nil {
requestBody, err = json.Marshal(payload)
if err != nil {
return err
}
}
req, err := http.NewRequest(method, u.String(), nil)
if err != nil {
return err
}
if err = c.signRequest(req, nil); err != nil {
if err = c.signRequest(req, requestBody); err != nil {
return err
}
resp, err := c.http.Do(req)
+6
View File
@@ -228,6 +228,12 @@ type ThirdPartyConfigAdapter interface {
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
}
// RealNamePolicyConfigAdapter reads tenant-level real-name review policy from
// the control database.
type RealNamePolicyConfigAdapter interface {
RealNamePolicy(t.TenantID) (*t.RealNamePolicy, error)
}
// TenantEntryAdapter stores public tenant entry data before a user is authenticated.
type TenantEntryAdapter interface {
PublicUploadCreate(*t.PublicUpload) (*t.PublicUpload, error)
+8 -3
View File
@@ -594,6 +594,11 @@ func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error {
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
realname_review_policy TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Real-name review policy: 0 none, 1 auto then manual',
realname_provider_mode VARCHAR(32) NOT NULL DEFAULT 'id_card_2factor' COMMENT 'id_card_2factor or smart_verify',
realname_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Require real-name verification',
realname_allow_skip TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Allow skipping real-name reminder',
realname_remind_after VARCHAR(32) NOT NULL DEFAULT '24h' COMMENT 'Real-name reminder interval',
aliyun_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable Aliyun content moderation',
local_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Enable local content moderation',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@@ -3309,9 +3314,9 @@ func (a *adapter) MessageSave(msg *t.Message) error {
// Using a sequential ID provided by the database.
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, err := a.db.ExecContext(ctx,
"INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?)",
"INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content))
store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
if isDupe(err) {
return t.ErrDuplicate
}
@@ -3364,7 +3369,7 @@ func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) (
rows, err := a.db.QueryxContext(
ctx,
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content"+
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content,m.content_nonce,m.key_version"+
" FROM im_messages AS m LEFT JOIN im_dellog AS d"+
" ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+
" WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+
+2 -1
View File
@@ -97,7 +97,8 @@ func ensureGroupMessageShard(exec sqlExecutor, start time.Time, status string) e
message_id BIGINT NOT NULL, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
content JSON, content_nonce VARBINARY(12), key_version INT NOT NULL DEFAULT 0,
head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
+2 -1
View File
@@ -27,7 +27,8 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
content JSON, content_nonce VARBINARY(12), key_version INT NOT NULL DEFAULT 0,
head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
+57
View File
@@ -0,0 +1,57 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"errors"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) RealNamePolicy(tenantID t.TenantID) (*t.RealNamePolicy, error) {
if !tenantID.IsValid() {
return nil, t.ErrMalformed
}
db := a.registryDB
if db == nil {
db = a.db
}
if db == nil {
return nil, t.ErrInternal
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var policy t.RealNamePolicy
err := db.GetContext(ctx, &policy, `SELECT tenant_id, realname_review_policy, realname_provider_mode,
realname_required, realname_allow_skip, realname_remind_after
FROM im_tenant_config WHERE tenant_id=? LIMIT 1`, tenantID)
if errors.Is(err, sql.ErrNoRows) {
policy = *defaultRealNamePolicy(tenantID)
return &policy, nil
}
if isUnknownColumn(err) {
policy = *defaultRealNamePolicy(tenantID)
return &policy, nil
}
if err != nil {
return nil, err
}
policy.Normalize()
return &policy, nil
}
func defaultRealNamePolicy(tenantID t.TenantID) *t.RealNamePolicy {
policy := &t.RealNamePolicy{
TenantID: tenantID,
ReviewPolicy: t.RealNameReviewPolicyNone,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}
policy.Normalize()
return policy
}
+39 -24
View File
@@ -2065,8 +2065,8 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
res, err := a.db.ExecContext(ctx,
"INSERT INTO im_messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)",
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content))
"INSERT INTO im_messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion)
if isDupe(err) {
return t.ErrDuplicate
}
@@ -2139,12 +2139,12 @@ func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
}
query := fmt.Sprintf(`INSERT INTO %s
(message_id,topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
file_count,content_size,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, table)
file_count,content_size,version,created_at,updated_at,content_nonce,key_version)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, table)
_, err = tx.ExecContext(ctx, query,
messageID, msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt)
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt, msg.ContentNonce, msg.KeyVersion)
if isDupe(err) {
return t.ErrDuplicate
}
@@ -2191,7 +2191,7 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := a.db.QueryRowxContext(ctx,
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,content_nonce,key_version "+
"FROM im_messages WHERE `from`=? AND client_msg_id=?",
decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
@@ -2246,7 +2246,7 @@ func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, f
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,'%s' AS shard_month FROM %s WHERE topic=? AND from_user=? AND client_msg_id=?", idColumn, month, table)
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,content_nonce,key_version,'%s' AS shard_month FROM %s WHERE topic=? AND from_user=? AND client_msg_id=?", idColumn, month, table)
err = a.db.QueryRowxContext(ctx, query, topic, decodeUID(from), clientMsgID).StructScan(&row)
if err == nil {
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
@@ -2442,13 +2442,14 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, acto
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,content_nonce,key_version,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion, strings.TrimSpace(reason)); err != nil {
return nil, err
}
storedContent, nonce, keyVersion := messageStoredContent(content)
_, err = tx.ExecContext(ctx,
"UPDATE im_messages SET updatedat=?,version=version+1,head=?,content=? WHERE topic=? AND seqid=? AND version=?",
editedAt, head, common.ToJSON(content), topic, seq, expectedVersion)
"UPDATE im_messages SET updatedat=?,version=version+1,head=?,content=?,content_nonce=?,key_version=? WHERE topic=? AND seqid=? AND version=?",
editedAt, head, common.ToJSON(storedContent), nonce, keyVersion, topic, seq, expectedVersion)
if err != nil {
return nil, err
}
@@ -2496,14 +2497,15 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,content_nonce,key_version,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion, strings.TrimSpace(reason)); err != nil {
return nil, err
}
contentJSON := common.ToJSON(content)
storedContent, nonce, keyVersion := messageStoredContent(content)
contentJSON := common.ToJSON(storedContent)
_, err = tx.ExecContext(ctx, fmt.Sprintf(
"UPDATE %s SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?", messageTable),
editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion)
"UPDATE %s SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_nonce=?,key_version=?,content_size=? WHERE topic=? AND seqid=? AND version=?", messageTable),
editedAt, messageHeadType(head), head, contentJSON, nonce, keyVersion, len(contentJSON), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
@@ -2561,7 +2563,7 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,m.content_nonce,m.key_version "+
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+
"AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.delid=0 AND m.topic=? "+
seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
@@ -2640,7 +2642,7 @@ func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Ui
args := []any{store.DecodeUid(uid), topic}
args = append(args, seqArgs...)
args = append(args, limit-len(msgs))
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.status!='deleted' AND m.topic=? %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, seqClause)
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,m.content_nonce,m.key_version,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.status!='deleted' AND m.topic=? %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, seqClause)
rows, queryErr := a.db.QueryxContext(ctx, query, args...)
if queryErr != nil {
return nil, queryErr
@@ -2715,7 +2717,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
queryArgs := append([]any{decodeUID(uid)}, args...)
queryArgs = append(queryArgs, scanLimit)
rows, err := a.db.QueryxContext(ctx,
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,m.content_nonce,m.key_version "+
"FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.topic=m.topic "+
"AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
queryArgs...)
@@ -2796,7 +2798,7 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
}
queryArgs := append([]any{store.DecodeUid(uid)}, args...)
queryArgs = append(queryArgs, scanLimit-len(seen))
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
query := fmt.Sprintf("SELECT m.%s AS id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,IFNULL(m.client_msg_id,'') AS client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content,m.content_nonce,m.key_version,'%s' AS shard_month FROM %s AS m LEFT JOIN im_dellog AS d ON d.topic COLLATE utf8mb4_unicode_ci=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE %s AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", idColumn, month, table, strings.Join(where, " AND "))
rows, queryErr := a.db.QueryxContext(ctx, query, queryArgs...)
if queryErr != nil {
return nil, "", queryErr
@@ -2863,6 +2865,10 @@ func messageSearchScanLimit(limit int, queryText string) int {
}
func messageMatchesSearch(msg t.Message, queryText string) bool {
if msg.KeyVersion > 0 {
// Store decrypts and filters encrypted messages after the adapter returns.
return true
}
queryText = normalizeMessageSearchText(queryText)
if queryText == "" {
return true
@@ -3144,8 +3150,10 @@ func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int64, stat
_, err := a.db.ExecContext(ctx,
`UPDATE im_call_records
SET status=?,end_reason=?,duration_ms=?,finished_at=?,updated_at=?
WHERE call_id=? AND seq_id=?`,
status, status, durationMS, finishedAt, finishedAt, callID, seq)
WHERE call_id=? AND seq_id=? AND finished_at IS NULL
AND NOT (status=? AND ? IN (?,?))`,
status, status, durationMS, finishedAt, finishedAt, callID, seq,
t.CallStatusAccepted, status, t.CallStatusMissed, t.CallStatusDeclined)
return err
}
@@ -4074,7 +4082,7 @@ func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := sqlx.GetContext(ctx, q, &row,
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content "+
"SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,content_nonce,key_version "+
"FROM im_messages WHERE topic=? AND seqid=?"+suffix,
topic, seq)
if err != nil {
@@ -4119,7 +4127,7 @@ func tenantGroupMessageGetOneWithTable(q sqlx.QueryerContext, ctx context.Contex
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,'%s' AS shard_month FROM %s WHERE topic=? AND seqid=?%s", idColumn, month, table, suffix)
query := fmt.Sprintf("SELECT %s AS id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,IFNULL(client_msg_id,'') AS client_msg_id,version,recalled_at,recalled_by,head,content,content_nonce,key_version,'%s' AS shard_month FROM %s WHERE topic=? AND seqid=?%s", idColumn, month, table, suffix)
err = sqlx.GetContext(ctx, q, &row, query, topic, seq)
if err == nil {
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
@@ -4139,6 +4147,13 @@ func messageVersion(version int) int {
return version
}
func messageStoredContent(value any) (content any, nonce []byte, keyVersion int) {
if stored, ok := value.(t.StoredMessageContent); ok {
return stored.Content, stored.Nonce, stored.KeyVersion
}
return value, nil, 0
}
func kvMapFromJSON(value any) t.KVMap {
switch parsed := common.FromJSON(value).(type) {
case map[string]any:
+1
View File
@@ -318,6 +318,7 @@ func acceptIMCallFromRTCJoin(principal *imPrincipal, record *types.CallRecord) {
}
return
}
publishRealtimeCallEvent(msg)
select {
case globals.hub.routeCli <- msg:
default:
+112 -8
View File
@@ -26,6 +26,7 @@ import (
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/moderation"
realnamealiyun "github.com/tinode/chat/server/realname/aliyun"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
@@ -1691,17 +1692,20 @@ func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
return
}
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
status, reviewReason, verifiedAt := initialRealNameSubmissionState(req, principal, name, idType, idNumber)
rv := &types.RealNameVerification{
TenantID: principal.TenantID,
UserID: principal.Uid,
SubmissionID: submissionID,
Status: "pending",
Status: status,
NameCipher: protectIMSensitive(principal.TenantID, name),
IDType: idType,
IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber),
IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"),
MaskedName: maskIMName(name),
MaskedIDNumber: maskIMIDNumber(idNumber),
ReviewReason: reviewReason,
VerifiedAt: verifiedAt,
}
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
if errors.Is(err, types.ErrDuplicate) {
@@ -1711,7 +1715,12 @@ func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
response := map[string]any{"submission_id": submissionID, "status": status}
if status == "manual_review" {
response["status"] = "pending"
response["review_status"] = "manual_review"
}
writeIMSuccess(wrt, req, response)
}
func serveIMRealNameAppeals(wrt http.ResponseWriter, req *http.Request) {
@@ -2563,7 +2572,8 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
head, content := favoriteForwardMessageHeadContent(item)
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite", target, favoriteID)
head, content := favoriteForwardMessageHeadContent(item, clientMid)
msg := &types.Message{
TenantID: principal.TenantID,
Topic: target,
@@ -2581,6 +2591,7 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
if saved != nil && saved.Pending {
result := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
result["favorite_id"] = favoriteID
result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
@@ -2588,6 +2599,7 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
if msg.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
result := forwardResultUnknownPayload(target, msg.SeqId, relationErrorReason(err))
result["favorite_id"] = favoriteID
result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
@@ -2597,11 +2609,12 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
}
if saved != nil && saved.Pending {
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}
result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
continue
}
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId}
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId, "client_mid": clientMid}
mergeForwardCommentResult(result, commentResult)
results = append(results, result)
}
@@ -6571,13 +6584,20 @@ func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, t
func realNamePayload(rv *types.RealNameVerification) map[string]any {
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
status := rv.Status
if status == "manual_review" {
status = "pending"
}
data := map[string]any{
"status": rv.Status,
"status": status,
"submission_id": rv.SubmissionID,
"masked_name": rv.MaskedName,
"masked_id_number": rv.MaskedIDNumber,
"can_submit": canSubmit,
}
if rv.Status == "manual_review" {
data["review_status"] = rv.Status
}
if rv.VerifiedAt != nil {
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
}
@@ -6604,6 +6624,89 @@ func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any {
}
}
func initialRealNameSubmissionState(req *http.Request, principal *imPrincipal, name, idType, idNumber string) (string, *string, *time.Time) {
status := "pending"
policy, err := store.RealNameConfig.Policy(principal.TenantID)
if err != nil || policy == nil {
logs.Warn.Printf("realname policy lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
return status, nil, nil
}
if policy.ReviewPolicy != types.RealNameReviewPolicyAutoManual {
return status, nil, nil
}
result := reviewIMRealNameAutomatically(req, principal, policy, name, idType, idNumber)
switch result.status {
case "verified":
now := time.Now().UTC().Round(time.Millisecond)
return "verified", nil, &now
default:
reason := strings.TrimSpace(result.reason)
if reason == "" {
reason = "自动审核未通过,已转人工审核"
}
return "manual_review", &reason, nil
}
}
type imRealNameAutoReviewResult struct {
status string
reason string
}
func reviewIMRealNameAutomatically(req *http.Request, principal *imPrincipal, policy *types.RealNamePolicy, name, idType, idNumber string) imRealNameAutoReviewResult {
values, err := store.ThirdPartyConfig.Get("im_realname",
"realname_provider",
"aliyun_region_id",
"aliyun_endpoint",
"aliyun_access_key_id",
"aliyun_access_key_secret",
"aliyun_timeout_ms",
"mock_enabled",
"mock_result")
if err != nil {
logs.Warn.Printf("realname auto review config lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置读取失败,已转人工审核"}
}
if parseIMBool(values["mock_enabled"]) {
switch strings.ToLower(strings.TrimSpace(values["mock_result"])) {
case "verified", "pass", "passed", "success":
return imRealNameAutoReviewResult{status: "verified"}
default:
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
}
}
if strings.TrimSpace(values["realname_provider"]) == "" ||
strings.TrimSpace(values["aliyun_access_key_id"]) == "" ||
strings.TrimSpace(values["aliyun_access_key_secret"]) == "" {
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置未完成,已转人工审核"}
}
if !strings.EqualFold(strings.TrimSpace(values["realname_provider"]), "aliyun_cloudauth") {
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核供应商暂不支持,已转人工审核"}
}
client, err := realnamealiyun.NewID2MetaClient(realnamealiyun.Config{
AccessKeyID: values["aliyun_access_key_id"],
AccessKeySecret: values["aliyun_access_key_secret"],
RegionID: values["aliyun_region_id"],
Endpoint: values["aliyun_endpoint"],
TimeoutMS: realnamealiyun.ParseTimeoutMS(values["aliyun_timeout_ms"], 1500),
})
if err != nil {
logs.Warn.Printf("realname auto review client init failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核客户端初始化失败,已转人工审核"}
}
result, err := client.Verify(name, idNumber)
if err != nil {
logs.Warn.Printf("realname auto review call failed: tenant=%d uid=%s trace=%s err=%v", principal.TenantID, principal.Uid, req.Header.Get("X-Request-ID"), err)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核调用失败,已转人工审核"}
}
if result.Passed {
logs.Info.Printf("realname auto review passed: tenant=%d uid=%s request_id=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.Latency)
return imRealNameAutoReviewResult{status: "verified"}
}
logs.Info.Printf("realname auto review mismatch: tenant=%d uid=%s request_id=%s biz_code=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.BizCode, result.Latency)
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
}
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
if inv == nil {
return map[string]any{}
@@ -6770,7 +6873,7 @@ func parseIMNonNegativeCursor(raw string) (int, error) {
func parseIMBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y":
case "1", "true", "yes", "y", "on", "enabled":
return true
default:
return false
@@ -9038,7 +9141,7 @@ func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string
return payload
}
func favoriteForwardMessageHeadContent(item *types.FavoriteItem) (types.KVMap, any) {
func favoriteForwardMessageHeadContent(item *types.FavoriteItem, clientMid string) (types.KVMap, any) {
itemType := "text"
if item != nil && strings.TrimSpace(item.ItemType) != "" {
itemType = strings.TrimSpace(item.ItemType)
@@ -9059,12 +9162,13 @@ func favoriteForwardMessageHeadContent(item *types.FavoriteItem) (types.KVMap, a
if _, ok := head["type"]; !ok {
head["type"] = itemType
}
head["x-im-client-mid"] = clientMid
if content, ok := snapshot["content"]; ok {
return head, content
}
}
}
return forwardBundleMessageHead(""), favoriteForwardContent(item, "")
return forwardBundleMessageHead(clientMid), favoriteForwardContent(item, "")
}
func forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any {
+138 -24
View File
@@ -226,6 +226,24 @@ func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]str
return result, nil
}
type testRealNameConfigStore struct {
policy *types.RealNamePolicy
err error
}
func (s testRealNameConfigStore) Policy(tenantID types.TenantID) (*types.RealNamePolicy, error) {
if s.err != nil {
return nil, s.err
}
if s.policy == nil {
return &types.RealNamePolicy{TenantID: tenantID, ReviewPolicy: types.RealNameReviewPolicyNone, ProviderMode: "id_card_2factor", AllowSkip: true, RemindAfter: "24h"}, nil
}
policy := *s.policy
policy.TenantID = tenantID
policy.Normalize()
return &policy, nil
}
type testConversationsStore struct {
topic string
settings *types.ConversationSettings
@@ -2096,6 +2114,48 @@ func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) {
}
}
func TestServeIMRealNameSubmissionAutoReviewFallsBackToManual(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
oldRealNameConfig := store.RealNameConfig
oldThirdParty := store.ThirdPartyConfig
security := &testSecurityStore{}
store.Security = security
store.RealNameConfig = testRealNameConfigStore{policy: &types.RealNamePolicy{
ReviewPolicy: types.RealNameReviewPolicyAutoManual,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}}
store.ThirdPartyConfig = testThirdPartyConfigStore{}
defer func() {
store.Security = oldSecurity
store.RealNameConfig = oldRealNameConfig
store.ThirdPartyConfig = oldThirdParty
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions",
bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "idem-1")
rec := httptest.NewRecorder()
serveIMRealNameSubmissions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.realName == nil || security.realName.Status != "manual_review" || security.realName.ReviewReason == nil {
t.Fatalf("unexpected stored realname: %#v", security.realName)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != "pending" || data["review_status"] != "manual_review" {
t.Fatalf("unexpected response: %#v", data)
}
}
func TestServeIMRealNameAppealCreatesPending(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5087,6 +5147,7 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
mockStore.EXPECT().GetUidString().Return("fav-mid")
mockStore.EXPECT().GetUidString().Return("comment-mid")
store.Store = mockStore
oldFavorites := store.Favorites
@@ -5137,6 +5198,9 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
if msg.Head["x-im-forwarded"] != true {
t.Fatalf("forward marker not set: %#v", msg.Head)
}
if msg.Head["x-im-client-mid"] != "fwd_fav-mid" {
t.Fatalf("forward client mid not set: %#v", msg.Head)
}
content := msg.Content.(map[string]any)
if content["text"] != "hello" || content["comment"] != nil || content["snapshot"] != nil {
t.Fatalf("unexpected forward content: %#v", content)
@@ -5153,11 +5217,82 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String())
}
items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any)
if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 || got["comment_seq"].(float64) != 10 {
if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 || got["comment_seq"].(float64) != 10 || got["client_mid"] != "fwd_fav-mid" {
t.Fatalf("unexpected forward response: %#v", got)
}
}
func TestServeIMFavoriteForwardMediaUsesNewClientMid(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
mockStore.EXPECT().GetUidString().Return("media-mid")
store.Store = mockStore
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7,
FavoriteID: "fav_img",
UserID: 42,
ItemType: "image",
SourceTopic: "grpAlpha",
SourceSeq: 17,
Snapshot: map[string]any{
"head": map[string]any{"type": "image", "x-im-type": "image", "x-im-client-mid": "origin-mid"},
"content": map[string]any{"file_id": "file-img", "mime": "image/png"},
},
Availability: "available",
CollectedAt: now,
}}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Store = oldStore
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), "grpBeta", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7, User: types.Uid(42).String(), Topic: "grpBeta", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn(
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
if msg.Topic != "grpBeta" || msg.Head["x-im-type"] != "image" {
t.Fatalf("unexpected media forward message: %#v", msg)
}
if msg.Head["x-im-client-mid"] != "fwd_media-mid" {
t.Fatalf("media forward reused original client mid: %#v", msg.Head)
}
content := msg.Content.(map[string]any)
if content["file_id"] != "file-img" || content["mime"] != "image/png" {
t.Fatalf("unexpected media forward content: %#v", content)
}
return &types.MessageSaveResult{SeqId: 22, MarkedReadBySender: true}, nil
})
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_img"],"target_topics":["grpBeta"]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavoriteResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String())
}
items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any)
if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 22 || got["client_mid"] != "fwd_media-mid" {
t.Fatalf("unexpected media forward response: %#v", got)
}
}
func TestServeIMFavoriteForwardRejectsUnauthorizedTarget(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5511,7 +5646,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
t.Fatalf("unexpected forward response: %#v", data)
}
got := items[0].(map[string]any)
if got["target_topic"] != targetTopic || got["seq"].(float64) != 35 {
if got["target_topic"] != targetTopic || got["seq"].(float64) != 35 || got["status"] != "pending" || got["reason"] != "MESSAGE_PERSIST_PENDING" {
t.Fatalf("unexpected forward item: %#v", got)
}
if failed := data["failed"].([]any); len(failed) != 0 {
@@ -5525,29 +5660,8 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
}
select {
case delivered := <-globals.hub.routeSrv:
if delivered.RcptTo != targetTopic || delivered.Data == nil || delivered.Data.SeqId != 35 {
t.Fatalf("unexpected broadcast message: %#v", delivered)
}
t.Fatalf("pending forward should not broadcast immediately: %#v", delivered)
default:
t.Fatal("expected forwarded message to be broadcast")
}
presByUser := make(map[string]*MsgServerPres)
for range 2 {
select {
case delivered := <-globals.hub.routeSrv:
if delivered.Pres == nil {
t.Fatalf("unexpected non-presence message: %#v", delivered)
}
presByUser[delivered.RcptTo] = delivered.Pres
default:
t.Fatal("expected forwarded message presence notifications")
}
}
if pres := presByUser[types.Uid(42).UserId()]; pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != targetUser.UserId() || pres.SeqId != 35 || pres.AcsActor != "" || pres.SkipTopic != targetTopic {
t.Fatalf("unexpected sender presence: %#v", pres)
}
if pres := presByUser[targetUser.UserId()]; pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != types.Uid(42).UserId() || pres.SeqId != 35 || pres.AcsActor != types.Uid(42).UserId() || pres.SkipTopic != targetTopic {
t.Fatalf("unexpected receiver presence: %#v", pres)
}
}
+89 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -127,11 +128,23 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
result, err := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage)
var result *types.InternalAdminQueryResult
keyword := strings.TrimSpace(req.URL.Query().Get("keyword"))
if (resource == "private-messages" || resource == "group-messages") && keyword != "" {
result, err = queryEncryptedAdminMessages(req, tenantID, spec, scope, keyword, page, perPage)
} else {
result, err = store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage)
}
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
if resource == "private-messages" || resource == "group-messages" {
if err = decryptInternalAdminMessageRows(tenantID, result.Items); err != nil {
writeInternalQueryError(wrt, req, err)
return
}
}
writeIMSuccess(wrt, req, map[string]any{
"items": normalizeInternalAdminRowsForAPI(result.Items),
"total": result.Total,
@@ -140,6 +153,81 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
})
}
func queryEncryptedAdminMessages(req *http.Request, tenantID types.TenantID, spec *internalQuerySpec, scope, keyword string, page, perPage int) (*types.InternalAdminQueryResult, error) {
requestCopy := req.Clone(req.Context())
urlCopy := *req.URL
queryValues := req.URL.Query()
queryValues.Del("keyword")
urlCopy.RawQuery = queryValues.Encode()
requestCopy.URL = &urlCopy
query, countQuery, args, _, _, err := buildInternalAdminListQuery(requestCopy, spec, scope)
if err != nil {
return nil, err
}
const batchSize = 200
start := (page - 1) * perPage
end := start + perPage
items := make([]map[string]any, 0, perPage)
matched := 0
for batchPage := 1; ; batchPage++ {
batch, queryErr := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, batchPage, batchSize)
if queryErr != nil {
return nil, queryErr
}
if err = decryptInternalAdminMessageRows(tenantID, batch.Items); err != nil {
return nil, err
}
for _, row := range batch.Items {
if !strings.Contains(strings.ToLower(fmt.Sprint(row["content_excerpt"])), strings.ToLower(keyword)) {
continue
}
if matched >= start && matched < end {
items = append(items, row)
}
matched++
}
if len(batch.Items) < batchSize || int64(batchPage*batchSize) >= batch.Total {
break
}
}
return &types.InternalAdminQueryResult{Items: items, Total: int64(matched), Page: page, PerPage: perPage}, nil
}
func decryptInternalAdminMessageRows(tenantID types.TenantID, rows []map[string]any) error {
for _, row := range rows {
seq, ok := numericID(row["seq_id"])
if !ok || seq <= 0 {
continue
}
msg, err := store.Messages.GetOne(tenantID, fmt.Sprint(row["topic"]), seq)
if err != nil {
return err
}
row["content_excerpt"] = internalMessageExcerpt(msg.Content)
}
return nil
}
func internalMessageExcerpt(content any) string {
if value, ok := content.(map[string]any); ok {
for _, key := range []string{"txt", "text", "content", "title"} {
if text := strings.TrimSpace(fmt.Sprint(value[key])); text != "" && text != "<nil>" {
if len([]rune(text)) > 500 {
return string([]rune(text)[:500])
}
return text
}
}
}
raw, _ := json.Marshal(content)
text := string(raw)
if len([]rune(text)) > 500 {
text = string([]rune(text)[:500])
}
return text
}
func serveInternalAdminResourceDetail(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, id string) {
spec, ok := internalAdminQuerySpec(resource)
if !ok || spec.DetailKey == "" {
+7
View File
@@ -607,6 +607,13 @@ func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenan
writeInternalQueryError(wrt, req, err)
return
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
row["content"] = msg.Content
row["content_excerpt"] = internalMessageExcerpt(msg.Content)
row["conversation_type"] = strings.TrimSuffix(resource, "-messages")
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
+9
View File
@@ -23,6 +23,8 @@ const (
configEventAppReleaseUpdated = "app.release.updated"
configEventSecurityPolicyUpdated = "security.policy.updated"
configEventSensitiveWordsUpdated = "sensitive_words.updated"
configEventMessageKeysUpdated = "message.keys.updated"
configEventMessageKeysActivated = "message.keys.activated"
)
type configEventPayload struct {
@@ -102,6 +104,13 @@ func executeKafkaConfigEvent(envelope kafkaEnvelope) error {
store.InvalidateSecurityPolicyCache(tenantCode)
case configEventSensitiveWordsUpdated:
store.InvalidateModerationConfigCache(tenantCode, false, false, true)
case configEventMessageKeysUpdated, configEventMessageKeysActivated:
store.InvalidateMessageEncryptionKeys(tenantID)
if strings.TrimSpace(envelope.EventType) == configEventMessageKeysUpdated && envelope.Version > 0 {
if err := store.PrepareMessageEncryptionKey(tenantID, tenantCode, int(envelope.Version)); err != nil {
return err
}
}
default:
store.InvalidateTenantConfigCache(tenantCode, tenantID)
store.InvalidateThirdPartyConfigCache(payload.Group)
+5 -5
View File
@@ -54,10 +54,10 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
if runtimeReady && !claimed {
if claim != nil && claim.Seq > 0 {
msg.SeqId = claim.Seq
return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: claim.Status == "done", Pending: claim.Status == "pending"}, nil
return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: true, Pending: claim.Status == "pending"}, nil
}
if claim != nil && claim.Status == "pending" {
return &types.MessageSaveResult{Pending: true}, types.ErrDuplicate
return &types.MessageSaveResult{Duplicate: true, Pending: true}, types.ErrDuplicate
}
return nil, types.ErrDuplicate
}
@@ -87,8 +87,8 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
return &types.MessageSaveResult{SeqId: msg.SeqId, Pending: true, MarkedReadBySender: readBySender && !fromUid.IsZero()}, err
}
if clientMsgID != "" {
if err = store.MarkMessageUniqueDone(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId, ""); err != nil {
logs.Warn.Printf("kafka message persist: failed to mark message unique key done topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
if err = store.MarkMessageUniquePending(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId); err != nil {
logs.Warn.Printf("kafka message persist: failed to mark message unique key pending topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
}
}
if isGroupMessage {
@@ -96,7 +96,7 @@ func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *ty
logs.Warn.Printf("kafka message persist: failed to cache pending hot message after publish topic=%s seq=%d: %v", msg.Topic, msg.SeqId, cacheErr)
}
}
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: readBySender && !fromUid.IsZero()}, nil
return &types.MessageSaveResult{SeqId: msg.SeqId, Pending: true, MarkedReadBySender: readBySender && !fromUid.IsZero()}, nil
}
func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) error {
+112
View File
@@ -0,0 +1,112 @@
package aliyun
import (
"errors"
"strconv"
"strings"
"time"
cloudauth "github.com/alibabacloud-go/cloudauth-20190307/v3/client"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/dara"
)
type Config struct {
AccessKeyID string
AccessKeySecret string
RegionID string
Endpoint string
TimeoutMS int
}
type ID2MetaResult struct {
Passed bool
BizCode string
RequestID string
ProviderCode string
ProviderMessage string
ProviderStatusCode int32
Latency time.Duration
}
type ID2MetaClient struct {
client *cloudauth.Client
timeoutMS int
}
func NewID2MetaClient(config Config) (*ID2MetaClient, error) {
if strings.TrimSpace(config.AccessKeyID) == "" || strings.TrimSpace(config.AccessKeySecret) == "" {
return nil, errors.New("missing aliyun realname credentials")
}
if strings.TrimSpace(config.RegionID) == "" {
config.RegionID = "cn-hangzhou"
}
clientConfig := &openapi.Config{
AccessKeyId: dara.String(strings.TrimSpace(config.AccessKeyID)),
AccessKeySecret: dara.String(strings.TrimSpace(config.AccessKeySecret)),
RegionId: dara.String(strings.TrimSpace(config.RegionID)),
Protocol: dara.String("https"),
}
if endpoint := strings.TrimSpace(config.Endpoint); endpoint != "" {
clientConfig.Endpoint = dara.String(endpoint)
}
client, err := cloudauth.NewClient(clientConfig)
if err != nil {
return nil, err
}
if config.TimeoutMS <= 0 {
config.TimeoutMS = 1500
}
return &ID2MetaClient{client: client, timeoutMS: config.TimeoutMS}, nil
}
func (c *ID2MetaClient) Verify(name, idNumber string) (*ID2MetaResult, error) {
if c == nil || c.client == nil {
return nil, errors.New("nil aliyun realname client")
}
name = strings.TrimSpace(name)
idNumber = strings.TrimSpace(idNumber)
if name == "" || idNumber == "" {
return nil, errors.New("missing realname identity fields")
}
started := time.Now()
resp, err := c.client.Id2MetaVerifyWithOptions(&cloudauth.Id2MetaVerifyRequest{
UserName: dara.String(name),
IdentifyNum: dara.String(idNumber),
ParamType: dara.String("normal"),
}, (&util.RuntimeOptions{}).SetConnectTimeout(c.timeoutMS).SetReadTimeout(c.timeoutMS).SetAutoretry(false))
latency := time.Since(started)
if err != nil {
return nil, err
}
result := &ID2MetaResult{Latency: latency}
if resp != nil {
result.ProviderStatusCode = dara.Int32Value(resp.StatusCode)
}
if resp == nil || resp.Body == nil {
return result, errors.New("empty aliyun realname response")
}
result.RequestID = dara.StringValue(resp.Body.RequestId)
result.ProviderCode = dara.StringValue(resp.Body.Code)
result.ProviderMessage = dara.StringValue(resp.Body.Message)
if resp.Body.ResultObject != nil {
result.BizCode = strings.TrimSpace(dara.StringValue(resp.Body.ResultObject.BizCode))
}
if result.ProviderCode != "" && result.ProviderCode != "200" {
return result, errors.New("aliyun realname response code " + result.ProviderCode)
}
if result.BizCode == "" {
return result, errors.New("empty aliyun realname biz code")
}
result.Passed = result.BizCode == "1"
return result, nil
}
func ParseTimeoutMS(value string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
@@ -0,0 +1,36 @@
package aliyun
import (
"os"
"testing"
)
func TestLiveID2MetaVerify(t *testing.T) {
accessKeyID := os.Getenv("ALIYUN_REALNAME_ACCESS_KEY_ID")
accessKeySecret := os.Getenv("ALIYUN_REALNAME_ACCESS_KEY_SECRET")
if accessKeyID == "" || accessKeySecret == "" {
t.Skip("ALIYUN_REALNAME_ACCESS_KEY_ID and ALIYUN_REALNAME_ACCESS_KEY_SECRET are required")
}
client, err := NewID2MetaClient(Config{
AccessKeyID: accessKeyID,
AccessKeySecret: accessKeySecret,
RegionID: envOrDefault("ALIYUN_REALNAME_REGION_ID", "cn-hangzhou"),
Endpoint: os.Getenv("ALIYUN_REALNAME_ENDPOINT"),
TimeoutMS: ParseTimeoutMS(os.Getenv("ALIYUN_REALNAME_TIMEOUT_MS"), 3000),
})
if err != nil {
t.Fatal(err)
}
result, err := client.Verify(envOrDefault("ALIYUN_REALNAME_TEST_NAME", "张三"), envOrDefault("ALIYUN_REALNAME_TEST_ID_NUMBER", "110101199003071234"))
if err != nil {
t.Fatalf("live id2meta verify failed: %v result=%+v", err, result)
}
t.Logf("live id2meta result: passed=%v biz_code=%s request_id=%s code=%s message=%s", result.Passed, result.BizCode, result.RequestID, result.ProviderCode, result.ProviderMessage)
}
func envOrDefault(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+108
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store"
@@ -16,6 +17,7 @@ import (
const (
realtimeKindMessage = "message"
realtimeKindSessionControl = "session_control"
realtimeKindCallEvent = "call_event"
realtimeTargetTopic = "topic"
realtimeTargetUser = "user"
@@ -23,6 +25,16 @@ const (
realtimeTargetPlatf = "platform"
)
type realtimeCallEventMessage struct {
Topic string `json:"topic"`
From string `json:"from"`
Sid string `json:"sid,omitempty"`
Event string `json:"event"`
Seq int64 `json:"seq"`
Payload json.RawMessage `json:"payload,omitempty"`
Timestamp time.Time `json:"ts,omitempty"`
}
func startRedisRealtimeFanout(ctx context.Context) {
if err := store.StartRealtimeFanoutSubscriber(ctx, currentNodeID(), deliverRealtimeFanoutEnvelope); err != nil {
immetrics.IncRealtimeFanout("subscribe", realtimeKindMessage, realtimeTargetTopic, "error")
@@ -65,6 +77,35 @@ func publishRealtimeUserMessage(uid types.Uid, msg *ServerComMessage) {
immetrics.IncRealtimeFanout("publish", realtimeKindMessage, realtimeTargetUser, "ok")
}
func publishRealtimeCallEvent(msg *ClientComMessage) {
if msg == nil || msg.Note == nil || !msg.TenantID.IsValid() ||
msg.RcptTo == "" || msg.AsUser == "" || msg.Note.What != "call" ||
!isDetachedCallEvent(msg.Note.Event) || globals.cluster != nil {
return
}
sid := ""
if msg.sess != nil {
sid = msg.sess.sid
}
event := realtimeCallEventMessage{
Topic: msg.RcptTo,
From: msg.AsUser,
Sid: sid,
Event: normalizeCallEvent(msg.Note.Event),
Seq: msg.Note.SeqId,
Payload: msg.Note.Payload,
Timestamp: msg.Timestamp,
}
dedupKey := "call:" + msg.RcptTo + ":" + msg.AsUser + ":" + event.Event + ":" + strconv.FormatInt(msg.Note.SeqId, 10)
if err := store.PublishRealtimeFanout(currentNodeID(), msg.TenantID, realtimeKindCallEvent, realtimeTargetTopic, msg.RcptTo, sid, dedupKey, event); err != nil {
immetrics.IncRealtimeFanout("publish", realtimeKindCallEvent, realtimeTargetTopic, "error")
logs.Warn.Printf("redis realtime fanout: publish call event topic=%s from=%s event=%s seq=%d failed: %v",
msg.RcptTo, msg.AsUser, event.Event, msg.Note.SeqId, err)
return
}
immetrics.IncRealtimeFanout("publish", realtimeKindCallEvent, realtimeTargetTopic, "ok")
}
func publishRealtimeEvictUser(tenantID types.TenantID, uid types.Uid, reason string, skipSid string) {
if !tenantID.IsValid() || uid.IsZero() {
immetrics.IncRealtimeFanout("publish", realtimeKindSessionControl, realtimeTargetUser, "skip_invalid")
@@ -175,12 +216,79 @@ func deliverRealtimeFanoutEnvelope(envelope *store.RealtimeFanoutEnvelope) {
switch envelope.Kind {
case realtimeKindSessionControl:
deliverRealtimeSessionControl(envelope, &msg)
case realtimeKindCallEvent:
deliverRealtimeCallEvent(envelope)
default:
immetrics.ObserveRealtimeFanoutDelivery(envelope.Kind, envelope.TargetType, msg.Timestamp)
deliverRealtimeMessage(envelope, &msg)
}
}
func deliverRealtimeCallEvent(envelope *store.RealtimeFanoutEnvelope) {
var event realtimeCallEventMessage
if err := json.Unmarshal(envelope.Message, &event); err != nil {
immetrics.IncRealtimeFanout("deliver", realtimeKindCallEvent, realtimeTargetTopic, "malformed")
logs.Warn.Println("redis realtime fanout: malformed call event", err)
return
}
topic := strings.TrimSpace(event.Topic)
if topic == "" {
topic = strings.TrimSpace(envelope.Target)
}
if topic == "" || event.From == "" || event.Seq <= 0 || !isDetachedCallEvent(event.Event) {
immetrics.IncRealtimeFanout("deliver", realtimeKindCallEvent, realtimeTargetTopic, "skip_invalid")
return
}
key := types.TopicKey{TenantID: envelope.TenantID, Topic: topic}
if globals.hub == nil || globals.hub.topicGet(key) == nil {
immetrics.IncRealtimeFanout("deliver", realtimeKindCallEvent, realtimeTargetTopic, "no_local_topic")
return
}
uid := types.ParseUserId(event.From)
if uid.IsZero() {
immetrics.IncRealtimeFanout("deliver", realtimeKindCallEvent, realtimeTargetTopic, "skip_invalid_user")
return
}
ts := event.Timestamp
if ts.IsZero() {
ts = types.TimeNow()
}
msg := &ClientComMessage{
Note: &MsgClientNote{
Topic: topic,
What: "call",
Event: normalizeCallEvent(event.Event),
SeqId: event.Seq,
Payload: event.Payload,
},
Original: topic,
RcptTo: topic,
AsUser: event.From,
AuthLvl: int(auth.LevelAuth),
Timestamp: ts,
TenantID: envelope.TenantID,
sess: &Session{
proto: NONE,
sid: "redis-call:" + strings.TrimSpace(event.Sid),
uid: uid,
tenantID: envelope.TenantID,
authLvl: auth.LevelAuth,
lastAction: ts.UnixNano(),
},
init: true,
}
if msg.sess.sid == "redis-call:" {
msg.sess.sid = "redis-call:" + event.From
}
select {
case globals.hub.routeCli <- msg:
immetrics.IncRealtimeFanout("deliver", realtimeKindCallEvent, realtimeTargetTopic, "hub_queued")
default:
immetrics.IncRealtimeFanout("deliver", realtimeKindCallEvent, realtimeTargetTopic, "hub_full")
logs.Err.Println("redis realtime fanout: hub routeCli queue full for call event", topic)
}
}
func deliverRealtimeMessage(envelope *store.RealtimeFanoutEnvelope, msg *ServerComMessage) {
if globals.sessionStore == nil {
immetrics.IncRealtimeFanout("deliver", envelope.Kind, envelope.TargetType, "skip_no_session_store")
+60
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"sync"
"testing"
"time"
@@ -259,6 +260,65 @@ func TestRealtimeP2PTopicFallbackRewritesVisibleInfoTopic(t *testing.T) {
}
}
func TestRealtimeCallEventRoutesToLocalTopic(t *testing.T) {
oldHub := globals.hub
defer func() { globals.hub = oldHub }()
tenantID := types.TenantID(1)
caller := types.Uid(1001)
callee := types.Uid(1002)
topic := caller.P2PName(callee)
if topic == "" {
t.Fatal("failed to create p2p topic")
}
topics := &sync.Map{}
topics.Store(types.TopicKey{TenantID: tenantID, Topic: topic}, &Topic{name: topic, tenantID: tenantID})
globals.hub = &Hub{
topics: topics,
routeCli: make(chan *ClientComMessage, 1),
}
payload := json.RawMessage(`{"callId":"call-1"}`)
event := realtimeCallEventMessage{
Topic: topic,
From: callee.UserId(),
Sid: "callee-sid",
Event: constCallEventAccept,
Seq: 12,
Payload: payload,
}
raw, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindCallEvent,
TargetType: realtimeTargetTopic,
Target: topic,
Message: raw,
})
select {
case got := <-globals.hub.routeCli:
if got.TenantID != tenantID || got.RcptTo != topic || got.AsUser != callee.UserId() {
t.Fatalf("unexpected routed message: tenant=%d rcpt=%q as=%q", got.TenantID, got.RcptTo, got.AsUser)
}
if got.Note == nil || got.Note.What != "call" || got.Note.Event != constCallEventAccept || got.Note.SeqId != 12 {
t.Fatalf("unexpected call note: %#v", got.Note)
}
if string(got.Note.Payload) != string(payload) {
t.Fatalf("payload = %s, want %s", got.Note.Payload, payload)
}
if got.sess == nil || got.sess.uid != callee || got.sess.sid != "redis-call:callee-sid" {
t.Fatalf("unexpected synthetic session: %#v", got.sess)
}
default:
t.Fatal("expected call event to be routed to local topic")
}
}
func TestRealtimeInfoDedupKeySeparatesReadRecv(t *testing.T) {
read := realtimeMessageDedupKey("p2pABC", &ServerComMessage{
Info: &MsgServerInfo{From: "usrA", What: "read", SeqId: 12},
+3
View File
@@ -1693,6 +1693,9 @@ func (s *Session) note(msg *ClientComMessage) {
}
return
}
if msg.Note.What == "call" {
publishRealtimeCallEvent(msg)
}
select {
case globals.hub.routeCli <- msg:
default:
+3
View File
@@ -65,6 +65,7 @@ var (
moderationPolicyCache = newConfigCache[*types.ModerationPolicy]()
moderationWordsCache = newConfigCache[*types.SensitiveWordSet]()
securityPolicyCache = newConfigCache[*types.SecurityPolicy]()
realNamePolicyCache = newConfigCache[*types.RealNamePolicy]()
thirdPartyConfigAllKeys = "*"
)
@@ -145,8 +146,10 @@ func InvalidateTenantConfigCache(code string, id types.TenantID) {
}
if id.IsValid() {
tenantByIDCache.delete(formatTenantIDForCache(id))
realNamePolicyCache.delete(formatTenantIDForCache(id))
} else if strings.TrimSpace(code) == "" {
tenantByIDCache.clear()
realNamePolicyCache.clear()
}
}
@@ -0,0 +1,191 @@
//go:build mysql
package store_test
import (
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/tinode/chat/server/db/mysql"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type localCryptoProvider struct {
key string
}
func (p localCryptoProvider) GetTenantByCode(string) (*types.Tenant, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) GetTenantByID(types.TenantID) (*types.Tenant, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ListActiveTenants(int) ([]types.Tenant, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) RegistrationSMSEnabled() (bool, error) {
return false, types.ErrUnsupported
}
func (p localCryptoProvider) ThirdPartyConfig(string, ...string) (map[string]string, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) LatestAppRelease(string, string) (*types.AppRelease, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ModerationProvider() (*types.ModerationProviderConfig, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) ModerationPolicy(string) (*types.ModerationPolicy, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) SensitiveWords(string) (*types.SensitiveWordSet, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) SecurityPolicy(string) (*types.SecurityPolicy, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) RealNamePolicy(types.TenantID) (*types.RealNamePolicy, error) {
return nil, types.ErrUnsupported
}
func (p localCryptoProvider) MessageKey(tenantCode string, version int) (*types.MessageKey, error) {
if tenantCode != "default" || (version != 0 && version != 7) {
return nil, types.ErrNotFound
}
return &types.MessageKey{TenantID: 1, TenantCode: tenantCode, Enabled: true, Version: 7, Algorithm: "AES-256-GCM", Status: "active", Key: p.key}, nil
}
func (p localCryptoProvider) MessageKeyAck(string, int) error { return nil }
func TestLocalMySQLMessageCryptoFlow(t *testing.T) {
if os.Getenv("IM_LOCAL_CRYPTO_TEST") != "1" {
t.Skip("set IM_LOCAL_CRYPTO_TEST=1 to run against local MySQL")
}
config := json.RawMessage(`{
"uid_key":"dkXxWLwqUNRfYmdXRzOkZw==",
"max_results":1024,
"use_adapter":"mysql",
"adapters":{"mysql":{
"dsn":"mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db":"hotgo",
"tenant_db_routing":true,
"sql_timeout":10
}}
}`)
if err := store.Store.Open(991, config); err != nil {
t.Fatal(err)
}
defer store.Store.Close()
defer store.SetAdminInternalConfigProvider(nil)
store.SetAdminInternalConfigProvider(localCryptoProvider{key: base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))})
store.InvalidateMessageEncryptionKeys(1)
db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci")
if err != nil {
t.Fatal(err)
}
defer db.Close()
var topic string
var senderID, originalTopicSeq int64
err = db.QueryRow(`SELECT s.topic,s.userid,t.seqid FROM im_subscriptions s JOIN im_topics t ON t.name=s.topic
WHERE s.topic LIKE 'p2p%' AND s.deletedat IS NULL ORDER BY t.id DESC LIMIT 1`).Scan(&topic, &senderID, &originalTopicSeq)
if err != nil {
t.Fatal(err)
}
sender := store.EncodeUid(senderID)
marker := fmt.Sprintf("LOCAL-CRYPTO-%d", time.Now().UnixNano())
msg := &types.Message{
TenantID: 1,
SeqId: originalTopicSeq + 1,
Topic: topic,
From: sender.String(),
Head: types.KVMap{"x-im-client-mid": marker, "x-im-type": "text"},
Content: map[string]any{"txt": marker},
}
msg.InitTimes()
msg.SetUid(store.Store.GetUid())
fallbackSeq := int64(0)
defer func() {
_, _ = db.Exec("DELETE FROM im_message_revisions WHERE topic=? AND seqid=?", topic, msg.SeqId)
_, _ = db.Exec("DELETE FROM im_messages WHERE topic=? AND seqid=?", topic, msg.SeqId)
if fallbackSeq > 0 {
_, _ = db.Exec("DELETE FROM im_messages WHERE topic=? AND seqid=?", topic, fallbackSeq)
}
_, _ = db.Exec("UPDATE im_topics SET seqid=? WHERE name=? AND seqid IN (?,?)", originalTopicSeq, topic, msg.SeqId, fallbackSeq)
}()
if _, err = store.PersistPreparedMessage(1, msg, nil, false); err != nil {
t.Fatal(err)
}
var rawContent string
var nonce []byte
var keyVersion int
if err = db.QueryRow("SELECT JSON_UNQUOTE(content),content_nonce,key_version FROM im_messages WHERE topic=? AND seqid=?", topic, msg.SeqId).Scan(&rawContent, &nonce, &keyVersion); err != nil {
t.Fatal(err)
}
if keyVersion != 7 || len(nonce) != 12 || strings.Contains(rawContent, marker) {
t.Fatalf("message not encrypted at rest: key_version=%d nonce=%d content=%q", keyVersion, len(nonce), rawContent)
}
if _, err = base64.StdEncoding.DecodeString(rawContent); err != nil {
t.Fatalf("stored ciphertext is not base64: %v", err)
}
loaded, err := store.Messages.GetOne(1, topic, msg.SeqId)
if err != nil || fmt.Sprint(loaded.Content.(map[string]any)["txt"]) != marker {
t.Fatalf("GetOne plaintext mismatch: content=%#v err=%v", loaded, err)
}
editedMarker := marker + "-EDITED"
edited, err := store.Messages.Edit(1, topic, msg.SeqId, sender, loaded.Version, loaded.Head, map[string]any{"txt": editedMarker}, "local integration test", types.TimeNow())
if err != nil || fmt.Sprint(edited.Content.(map[string]any)["txt"]) != editedMarker {
t.Fatalf("Edit plaintext mismatch: content=%#v err=%v", edited, err)
}
var revisionVersion, currentVersion int
if err = db.QueryRow("SELECT key_version FROM im_message_revisions WHERE topic=? AND seqid=? ORDER BY version DESC LIMIT 1", topic, msg.SeqId).Scan(&revisionVersion); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT key_version FROM im_messages WHERE topic=? AND seqid=?", topic, msg.SeqId).Scan(&currentVersion); err != nil {
t.Fatal(err)
}
if revisionVersion != 7 || currentVersion != 7 {
t.Fatalf("unexpected key versions: revision=%d current=%d", revisionVersion, currentVersion)
}
history, err := store.Messages.GetAll(1, topic, sender, &types.QueryOpt{Since: msg.SeqId, Before: msg.SeqId + 1, Limit: 10})
if err != nil || len(history) != 1 || fmt.Sprint(history[0].Content.(map[string]any)["txt"]) != editedMarker {
t.Fatalf("history plaintext mismatch: messages=%#v err=%v", history, err)
}
found, _, err := store.Messages.Search(1, topic, sender, editedMarker, "text", "", 10)
if err != nil || len(found) != 1 || found[0].SeqId != msg.SeqId {
t.Fatalf("search plaintext mismatch: messages=%#v err=%v", found, err)
}
store.SetAdminInternalConfigProvider(localCryptoProvider{})
store.InvalidateMessageEncryptionKeys(1)
fallback := &types.Message{
TenantID: 1,
SeqId: msg.SeqId + 1,
Topic: topic,
From: sender.String(),
Head: types.KVMap{"x-im-client-mid": marker + "-FALLBACK", "x-im-type": "text"},
Content: map[string]any{"txt": marker + "-PLAINTEXT"},
}
fallback.InitTimes()
fallback.SetUid(store.Store.GetUid())
fallbackSeq = fallback.SeqId
if _, err = store.PersistPreparedMessage(1, fallback, nil, false); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT JSON_UNQUOTE(content),key_version FROM im_messages WHERE topic=? AND seqid=?", topic, fallback.SeqId).Scan(&rawContent, &keyVersion); err != nil {
t.Fatal(err)
}
if keyVersion != 0 || !strings.Contains(rawContent, marker+"-PLAINTEXT") {
t.Fatalf("missing-key fallback was not plaintext: key_version=%d content=%q", keyVersion, rawContent)
}
}
+260
View File
@@ -0,0 +1,260 @@
package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store/types"
)
type cachedMessageKey struct {
enabled bool
version int
status string
key []byte
expires time.Time
}
const messageKeyCacheTTL = 5 * time.Minute
var messageKeyCache = struct {
sync.RWMutex
items map[string]cachedMessageKey
}{items: make(map[string]cachedMessageKey)}
func messageKeyCacheID(tenantID types.TenantID, version int) string {
return strconv.FormatInt(int64(tenantID), 10) + ":" + strconv.Itoa(version)
}
// WarmMessageEncryptionKeys preloads active keys. Missing keys leave the tenant in plaintext mode.
func WarmMessageEncryptionKeys() {
if adminInternalConfigProvider == nil || Tenants == nil {
return
}
tenants, err := Tenants.ListActive(10000)
if err != nil {
logs.Warn.Printf("message crypto: tenant preload failed: %v", err)
return
}
for i := range tenants {
if _, err = loadMessageKey(&tenants[i], 0); err != nil {
logs.Info.Printf("message crypto: plaintext mode tenant=%s reason=%v", tenants[i].Code, err)
}
}
}
func loadMessageKey(tenant *types.Tenant, version int) (cachedMessageKey, error) {
var zero cachedMessageKey
if tenant == nil || adminInternalConfigProvider == nil {
return zero, types.ErrNotFound
}
result, err := adminInternalConfigProvider.MessageKey(tenant.Code, version)
if err != nil || result == nil {
if err == nil {
err = types.ErrNotFound
}
return zero, err
}
decoded, err := base64.StdEncoding.DecodeString(result.Key)
if err != nil || len(decoded) != 32 {
return zero, errors.New("invalid tenant message key")
}
entry := cachedMessageKey{enabled: result.Enabled, version: result.Version, status: result.Status, key: decoded, expires: time.Now().Add(messageKeyCacheTTL)}
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenant.ID, result.Version)] = entry
if version == 0 {
messageKeyCache.items[messageKeyCacheID(tenant.ID, 0)] = entry
}
messageKeyCache.Unlock()
return entry, nil
}
func getMessageKey(tenantID types.TenantID, version int) (cachedMessageKey, error) {
cacheID := messageKeyCacheID(tenantID, version)
messageKeyCache.RLock()
entry, ok := messageKeyCache.items[cacheID]
messageKeyCache.RUnlock()
if ok && time.Now().Before(entry.expires) {
return entry, nil
}
if ok {
messageKeyCache.Lock()
delete(messageKeyCache.items, cacheID)
messageKeyCache.Unlock()
}
tenant, err := Tenants.GetByID(tenantID)
if err != nil {
return cachedMessageKey{}, err
}
entry, err = loadMessageKey(tenant, version)
if err != nil && version == 0 {
entry = cachedMessageKey{expires: time.Now().Add(messageKeyCacheTTL)}
messageKeyCache.Lock()
messageKeyCache.items[cacheID] = entry
messageKeyCache.Unlock()
return entry, nil
}
return entry, err
}
// PrepareMessageEncryptionKey verifies a pending key in memory and acknowledges
// it to Admin Server. Admin activates the key only after this succeeds.
func PrepareMessageEncryptionKey(tenantID types.TenantID, tenantCode string, version int) error {
if version <= 0 || adminInternalConfigProvider == nil {
return types.ErrMalformed
}
var tenant *types.Tenant
var err error
if tenantID.IsValid() {
tenant, err = Tenants.GetByID(tenantID)
} else {
tenant, err = Tenants.GetByCode(strings.TrimSpace(tenantCode))
}
if err != nil || tenant == nil {
return err
}
entry, err := loadMessageKey(tenant, version)
if err != nil {
return err
}
if strings.EqualFold(entry.status, "pending") {
if err = adminInternalConfigProvider.MessageKeyAck(tenant.Code, version); err != nil {
return err
}
}
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenant.ID, 0)] = entry
messageKeyCache.Unlock()
return nil
}
// InvalidateMessageEncryptionKeys forces the next access to reload tenant keys.
func InvalidateMessageEncryptionKeys(tenantID types.TenantID) {
prefix := strconv.FormatInt(int64(tenantID), 10) + ":"
messageKeyCache.Lock()
for key := range messageKeyCache.items {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
delete(messageKeyCache.items, key)
}
}
messageKeyCache.Unlock()
}
func messageAAD(msg *types.Message, keyVersion int) []byte {
topic := []byte(msg.Topic)
aad := make([]byte, 8+8+4+len(topic))
binary.BigEndian.PutUint64(aad[0:8], uint64(msg.TenantID))
binary.BigEndian.PutUint64(aad[8:16], uint64(msg.SeqId))
binary.BigEndian.PutUint32(aad[16:20], uint32(keyVersion))
copy(aad[20:], topic)
return aad
}
func encryptMessageContent(msg *types.Message) (*types.Message, error) {
entry, err := getMessageKey(msg.TenantID, 0)
if err != nil || !entry.enabled || entry.version <= 0 {
if err != nil {
logs.Warn.Printf("message crypto: active key unavailable, storing plaintext tenant=%d reason=%v", msg.TenantID, err)
}
copy := *msg
copy.KeyVersion, copy.ContentNonce = 0, nil
return &copy, nil
}
plain, err := json.Marshal(msg.Content)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(entry.key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
copy := *msg
copy.KeyVersion = entry.version
copy.ContentNonce = nonce
copy.Content = base64.StdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, messageAAD(&copy, entry.version)))
return &copy, nil
}
func decryptMessageContent(msg *types.Message) error {
if msg == nil || msg.KeyVersion <= 0 {
return nil
}
entry, err := getMessageKey(msg.TenantID, msg.KeyVersion)
if err != nil {
return fmt.Errorf("message key version %d unavailable: %w", msg.KeyVersion, err)
}
ciphertextText, ok := msg.Content.(string)
if !ok {
return errors.New("encrypted message content is not a string")
}
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextText)
if err != nil {
return err
}
block, err := aes.NewCipher(entry.key)
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
if len(msg.ContentNonce) != gcm.NonceSize() {
return fmt.Errorf("invalid message nonce length: got %d want %d", len(msg.ContentNonce), gcm.NonceSize())
}
plain, err := gcm.Open(nil, msg.ContentNonce, ciphertext, messageAAD(msg, msg.KeyVersion))
if err != nil {
return err
}
return json.Unmarshal(plain, &msg.Content)
}
func decryptMessages(messages []types.Message) error {
for i := range messages {
if err := decryptMessageContent(&messages[i]); err != nil {
return err
}
}
return nil
}
func messageContentMatchesSearch(content any, query string) bool {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return true
}
raw, err := json.Marshal(content)
if err != nil {
return false
}
text := strings.ToLower(string(raw))
if strings.Contains(text, query) {
return true
}
for _, term := range strings.Fields(query) {
if !strings.Contains(text, term) {
return false
}
}
return true
}
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"bytes"
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
func TestMessageCryptoRoundTrip(t *testing.T) {
tenantID := types.TenantID(9001)
key := bytes.Repeat([]byte{7}, 32)
messageKeyCache.Lock()
expires := time.Now().Add(time.Minute)
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{enabled: true, version: 3, key: key, expires: expires}
messageKeyCache.items[messageKeyCacheID(tenantID, 3)] = cachedMessageKey{enabled: true, version: 3, key: key, expires: expires}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
original := &types.Message{TenantID: tenantID, Topic: "p2p-test", SeqId: 42, Content: map[string]any{"txt": "hello"}}
stored, err := encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
if stored.KeyVersion != 3 || len(stored.ContentNonce) != 12 || stored.Content == original.Content {
t.Fatalf("message was not encrypted: %#v", stored)
}
if err = decryptMessageContent(stored); err != nil {
t.Fatal(err)
}
content, ok := stored.Content.(map[string]any)
if !ok || content["txt"] != "hello" {
t.Fatalf("unexpected plaintext: %#v", stored.Content)
}
}
func TestMessageCryptoPlaintextWhenDisabled(t *testing.T) {
tenantID := types.TenantID(9002)
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{expires: time.Now().Add(time.Minute)}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
original := &types.Message{TenantID: tenantID, Topic: "grp-test", SeqId: 7, Content: "plain"}
stored, err := encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
if stored.KeyVersion != 0 || stored.Content != "plain" || len(stored.ContentNonce) != 0 {
t.Fatalf("unexpected encrypted message: %#v", stored)
}
}
func TestMessageCryptoRejectsTampering(t *testing.T) {
tenantID := types.TenantID(9003)
key := bytes.Repeat([]byte{9}, 32)
expires := time.Now().Add(time.Minute)
messageKeyCache.Lock()
messageKeyCache.items[messageKeyCacheID(tenantID, 0)] = cachedMessageKey{enabled: true, version: 4, key: key, expires: expires}
messageKeyCache.items[messageKeyCacheID(tenantID, 4)] = cachedMessageKey{enabled: true, version: 4, key: key, expires: expires}
messageKeyCache.Unlock()
defer InvalidateMessageEncryptionKeys(tenantID)
original := &types.Message{TenantID: tenantID, Topic: "p2p-secure", SeqId: 8, Content: map[string]any{"txt": "secret"}}
stored, err := encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
stored.Topic = "p2p-tampered"
if err = decryptMessageContent(stored); err == nil {
t.Fatal("expected authenticated decryption to reject changed topic")
}
stored, err = encryptMessageContent(original)
if err != nil {
t.Fatal(err)
}
stored.ContentNonce = stored.ContentNonce[:len(stored.ContentNonce)-1]
if err = decryptMessageContent(stored); err == nil {
t.Fatal("expected invalid nonce length to be rejected")
}
}
func TestMessageCryptoSearchMatchesDecryptedContent(t *testing.T) {
content := map[string]any{"txt": "项目 验收通过", "nested": map[string]any{"title": "日报"}}
if !messageContentMatchesSearch(content, "验收") {
t.Fatal("expected plaintext keyword to match")
}
if messageContentMatchesSearch(content, "不存在") {
t.Fatal("unexpected keyword match")
}
}
+159 -11
View File
@@ -174,6 +174,9 @@ type AdminInternalConfigProvider interface {
ModerationPolicy(tenantCode string) (*types.ModerationPolicy, error)
SensitiveWords(tenantCode string) (*types.SensitiveWordSet, error)
SecurityPolicy(tenantCode string) (*types.SecurityPolicy, error)
RealNamePolicy(tenantID types.TenantID) (*types.RealNamePolicy, error)
MessageKey(tenantCode string, version int) (*types.MessageKey, error)
MessageKeyAck(tenantCode string, version int) error
}
var adminInternalConfigProvider AdminInternalConfigProvider
@@ -319,6 +322,61 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri
return result, err
}
type RealNameConfigPersistenceInterface interface {
Policy(tenantID types.TenantID) (*types.RealNamePolicy, error)
}
type realNameConfigMapper struct{}
var RealNameConfig RealNameConfigPersistenceInterface = realNameConfigMapper{}
func (realNameConfigMapper) Policy(tenantID types.TenantID) (*types.RealNamePolicy, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
cacheKey := formatTenantIDForCache(tenantID)
if cached, ok := realNamePolicyCache.get(cacheKey); ok {
cp := *cached
return &cp, nil
}
if adminInternalConfigProvider != nil {
if result, err := adminInternalConfigProvider.RealNamePolicy(tenantID); err == nil && result != nil {
result.Normalize()
realNamePolicyCache.set(cacheKey, result)
cp := *result
return &cp, nil
} else if err != nil {
logs.Warn.Printf("admin internal realname policy fallback failed: tenant=%d err=%v", tenantID, err)
}
}
configAdp, ok := adp.(adapter.RealNamePolicyConfigAdapter)
if !ok {
policy := defaultRealNamePolicy(tenantID)
realNamePolicyCache.set(cacheKey, policy)
return policy, nil
}
result, err := configAdp.RealNamePolicy(tenantID)
if err != nil {
return nil, err
}
result.Normalize()
realNamePolicyCache.set(cacheKey, result)
cp := *result
return &cp, nil
}
func defaultRealNamePolicy(tenantID types.TenantID) *types.RealNamePolicy {
policy := &types.RealNamePolicy{
TenantID: tenantID,
ReviewPolicy: types.RealNameReviewPolicyNone,
ProviderMode: "id_card_2factor",
AllowSkip: true,
RemindAfter: "24h",
}
policy.Normalize()
return policy
}
type TenantEntryPersistenceInterface interface {
CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error)
CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error)
@@ -1490,10 +1548,16 @@ func (messagesMapper) GetOne(tenantID types.TenantID, topic string, seq int64) (
if err != nil {
return nil, err
}
var msg *types.Message
if types.GetTopicCat(topic) == types.TopicCatGrp {
return tenantAdp.TenantGroupMessageGetOne(tenantID, topic, seq)
msg, err = tenantAdp.TenantGroupMessageGetOne(tenantID, topic, seq)
} else {
msg, err = tenantAdp.TenantMessageGetOne(tenantID, topic, seq)
}
return tenantAdp.TenantMessageGetOne(tenantID, topic, seq)
if err == nil {
err = decryptMessageContent(msg)
}
return msg, err
}
func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
@@ -1503,6 +1567,9 @@ func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, a
}
if types.GetTopicCat(topic) == types.TopicCatGrp {
msg, err := tenantAdp.TenantGroupMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
if err == nil {
err = decryptMessageContent(msg)
}
if err == nil && groupMsgRuntime != nil {
if cacheErr := groupMsgRuntime.CacheHotMessage(tenantID, msg); cacheErr != nil {
logs.Warn.Printf("topic[%s]: failed to refresh recalled group message cache: %v", topic, cacheErr)
@@ -1510,7 +1577,11 @@ func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, a
}
return msg, err
}
return tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
msg, err := tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
if err == nil {
err = decryptMessageContent(msg)
}
return msg, err
}
func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
@@ -1521,8 +1592,16 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, act
if err != nil {
return nil, err
}
stored, err := encryptMessageContent(&types.Message{TenantID: tenantID, Topic: topic, SeqId: seq, Content: content})
if err != nil {
return nil, err
}
storedContent := types.StoredMessageContent{Content: stored.Content, Nonce: stored.ContentNonce, KeyVersion: stored.KeyVersion}
if types.GetTopicCat(topic) == types.TopicCatGrp {
msg, err := tenantAdp.TenantGroupMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
msg, err := tenantAdp.TenantGroupMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, storedContent, reason, editedAt)
if err == nil {
err = decryptMessageContent(msg)
}
if err == nil && groupMsgRuntime != nil {
if cacheErr := groupMsgRuntime.CacheHotMessage(tenantID, msg); cacheErr != nil {
logs.Warn.Printf("topic[%s]: failed to refresh edited group message cache: %v", topic, cacheErr)
@@ -1530,7 +1609,11 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, act
}
return msg, err
}
return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
msg, err := tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, storedContent, reason, editedAt)
if err == nil {
err = decryptMessageContent(msg)
}
return msg, err
}
type ConversationsPersistenceInterface interface {
@@ -3237,10 +3320,14 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
msg.ClientMsgID = clientMsgID
fromUid := types.ParseUid(msg.From)
isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp
storedMsg, err := encryptMessageContent(msg)
if err != nil {
return nil, err
}
if isGroupMessage {
err = tenantAdp.TenantGroupMessageSave(tenantID, msg)
err = tenantAdp.TenantGroupMessageSave(tenantID, storedMsg)
} else {
err = tenantAdp.TenantMessageSave(tenantID, msg)
err = tenantAdp.TenantMessageSave(tenantID, storedMsg)
}
if err != nil {
if err == types.ErrDuplicate {
@@ -3250,6 +3337,9 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return nil, dupErr
}
if duplicate != nil {
if dupErr = decryptMessageContent(duplicate); dupErr != nil {
return nil, dupErr
}
msg.SeqId = duplicate.SeqId
msg.ShardMonth = duplicate.ShardMonth
if groupMsgRuntime != nil {
@@ -3264,11 +3354,14 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return nil, dupErr
}
if duplicate != nil {
if dupErr = decryptMessageContent(duplicate); dupErr != nil {
return nil, dupErr
}
copyPersistedMessage(msg, duplicate)
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
}
}
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, msg, isGroupMessage); retryErr != nil {
if retryErr := retryTenantMessageSaveAfterSeqConflict(tenantAdp, tenantID, storedMsg, isGroupMessage); retryErr != nil {
if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" {
_ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID)
}
@@ -3282,6 +3375,7 @@ func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Messag
return nil, err
}
}
msg.SetUid(storedMsg.Uid())
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return nil, err
}
@@ -3363,6 +3457,8 @@ func copyPersistedMessage(dst, src *types.Message) {
dst.ClientMsgID = src.ClientMsgID
dst.Head = src.Head
dst.Content = src.Content
dst.ContentNonce = src.ContentNonce
dst.KeyVersion = src.KeyVersion
}
func PrepareMessagePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
@@ -3392,7 +3488,17 @@ func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdap
return types.ErrDuplicate
}
prevSeq := msg.SeqId
plainMsg := *msg
if err = decryptMessageContent(&plainMsg); err != nil {
return err
}
msg.SeqId = latest[0].SeqId + 1
plainMsg.SeqId = msg.SeqId
if encryptedMsg, encryptErr := encryptMessageContent(&plainMsg); encryptErr != nil {
return encryptErr
} else {
*msg = *encryptedMsg
}
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return err
}
@@ -3647,9 +3753,17 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type
return msgs, nil
}
}
return tenantAdp.TenantGroupMessageGetAll(tenantID, topic, forUser, opt)
messages, err := tenantAdp.TenantGroupMessageGetAll(tenantID, topic, forUser, opt)
if err == nil {
err = decryptMessages(messages)
}
return messages, err
}
return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
messages, err := tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
if err == nil {
err = decryptMessages(messages)
}
return messages, err
}
func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
@@ -3663,7 +3777,40 @@ func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser type
if err != nil {
return nil, "", err
}
return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit)
query = strings.TrimSpace(query)
if query == "" {
messages, nextCursor, searchErr := tenantAdp.TenantMessageSearch(tenantID, topic, forUser, "", messageType, cursor, limit)
if searchErr == nil {
searchErr = decryptMessages(messages)
}
return messages, nextCursor, searchErr
}
// Encrypted content cannot be filtered by SQL. Walk visible messages in
// sequence order, decrypt them, then apply the same user-facing search.
matches := make([]types.Message, 0, limit)
nextCursor := cursor
for {
batch, batchCursor, searchErr := tenantAdp.TenantMessageSearch(tenantID, topic, forUser, "", messageType, nextCursor, 100)
if searchErr != nil {
return nil, "", searchErr
}
if searchErr = decryptMessages(batch); searchErr != nil {
return nil, "", searchErr
}
for _, msg := range batch {
if messageContentMatchesSearch(msg.Content, query) {
matches = append(matches, msg)
if len(matches) == limit {
return matches, strconv.FormatInt(msg.SeqId, 10), nil
}
}
}
if batchCursor == "" || len(batch) == 0 {
return matches, "", nil
}
nextCursor = batchCursor
}
}
func (messagesMapper) Mutations(tenantID types.TenantID, topic string, forUser types.Uid, sinceVersion int64, limit int) ([]types.MessageMutation, string, bool, int64, error) {
@@ -4661,6 +4808,7 @@ func init() {
Store = storeObj{}
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
RealNameConfig = realNameConfigMapper{}
TenantEntry = tenantEntryMapper{}
InternalAdminQueries = internalAdminQueryMapper{}
SecurityAudit = securityAuditMapper{}
+19
View File
@@ -0,0 +1,19 @@
package types
// MessageKey is a tenant message data-encryption key returned by Admin Server.
type MessageKey struct {
TenantID TenantID `json:"tenantId"`
TenantCode string `json:"tenantCode"`
Enabled bool `json:"enabled"`
Version int `json:"version"`
Algorithm string `json:"algorithm"`
Status string `json:"status"`
Key string `json:"key"`
}
// StoredMessageContent carries encrypted content metadata through the edit adapter boundary.
type StoredMessageContent struct {
Content any
Nonce []byte
KeyVersion int
}
+28
View File
@@ -2,6 +2,34 @@ package types
import "time"
const (
RealNameReviewPolicyNone = iota
RealNameReviewPolicyAutoManual
)
// RealNamePolicy controls tenant-level real-name review behavior.
type RealNamePolicy struct {
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
ReviewPolicy int `db:"realname_review_policy" json:"realname_review_policy"`
ProviderMode string `db:"realname_provider_mode" json:"realname_provider_mode"`
Required bool `db:"realname_required" json:"realname_required"`
AllowSkip bool `db:"realname_allow_skip" json:"realname_allow_skip"`
RemindAfter string `db:"realname_remind_after" json:"realname_remind_after"`
}
// Normalize fills defaults and clamps unsupported enum values.
func (p *RealNamePolicy) Normalize() {
if p.ProviderMode == "" {
p.ProviderMode = "id_card_2factor"
}
if p.RemindAfter == "" {
p.RemindAfter = "24h"
}
if p.ReviewPolicy != RealNameReviewPolicyAutoManual {
p.ReviewPolicy = RealNameReviewPolicyNone
}
}
// RealNameVerification is the latest real-name verification submission for a user.
type RealNameVerification struct {
ID int64 `db:"id" json:"-"`
+20 -16
View File
@@ -1250,26 +1250,30 @@ type Message struct {
Topic string
ShardMonth string `db:"shard_month" json:"-" bson:"shard_month,omitempty"`
// Sender's user ID as string (without 'usr' prefix), could be empty.
From string
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
Version int `json:"Version,omitempty" bson:",omitempty"`
RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"`
RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"`
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
From string
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
Version int `json:"Version,omitempty" bson:",omitempty"`
RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"`
RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"`
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
ContentNonce []byte `db:"content_nonce" json:"-" bson:"content_nonce,omitempty"`
KeyVersion int `db:"key_version" json:"-" bson:"key_version,omitempty"`
}
// MessageRevision stores an immutable snapshot of a message before editing.
type MessageRevision struct {
ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string
SeqId int64
Version int
Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
Reason string
ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string
SeqId int64
Version int
Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
Content any
ContentNonce []byte `db:"content_nonce" json:"-" bson:"content_nonce,omitempty"`
KeyVersion int `db:"key_version" json:"-" bson:"key_version,omitempty"`
Reason string
}
// MessageMutation describes an in-place message change used for offline compensation.
+1 -1
View File
@@ -1099,7 +1099,7 @@ func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
if err != nil {
t.Fatalf("saveMessageMaybeKafka returned error: %v", err)
}
if got == nil || got.SeqId != 1 || !got.MarkedReadBySender {
if got == nil || got.SeqId != 1 || !got.Pending || !got.MarkedReadBySender {
t.Fatalf("unexpected save result: %#v", got)
}
if msg.ClientMsgID != "client-1" {