Compare commits

...
26 Commits
Author SHA1 Message Date
richjohnrambo cae5039afe fix: verify OSS objects before serving 2026-09-09 16:28:56 +08:00
richjohnrambo c3e5cc0831 fix: reject dangling file attachment links 2026-09-09 15:55:18 +08:00
richjohnrambo bef158941a fix: validate group avatar attachments 2026-09-09 15:48:55 +08:00
richjohnrambo be61ec2b71 fix: backfill user public profile projections 2026-09-09 15:34:20 +08:00
richjohnrambo 45c6c7eacb fix: avoid admin dependency on login entrypoints 2026-09-09 15:19:13 +08:00
richjohnrambo 9159ec7fc7 feat: add IM service health and metrics endpoints 2026-09-09 14:24:40 +08:00
richjohnrambo 4c9bdd64e0 fix: 修复OSS头像预签名下载 2026-09-09 14:08:01 +08:00
richjohnrambo c43c099c5e fix: 兼容历史头像文件下载 2026-09-09 13:24:36 +08:00
richjohnrambo 92a72d1bea feat: enforce tenant login IP whitelists 2026-09-09 09:40:37 +08:00
richjohnrambo 9ac2b412cc fix: 优化群成员分页并补齐头像资料 2026-09-09 09:38:20 +08:00
richjohnrambo aae13c9265 feat: load oss media config from sys config 2026-09-08 22:19:00 +08:00
richjohnrambo 0f984b0ab4 Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 16:29:05 +08:00
richjohnrambo 0b3c0b1c78 fix: resolve P1 backend consistency issues 2026-09-08 16:23:21 +08:00
richjohnrambo 034678fa53 fix: 兼容查询用户头像数据 2026-09-08 15:53:56 +08:00
richjohnrambo 06945a4050 fix: 统一用户资料投影并放开头像访问 2026-09-08 15:26:05 +08:00
richjohnrambo 62e989487b Backfill group file metadata on read 2026-09-08 14:17:10 +08:00
richjohnrambo 20c0b92d77 Fix group file metadata and access 2026-09-08 13:50:04 +08:00
richjohnrambo 8116fb6fe8 feat: 完善用户治理与登录白名单 2026-09-08 10:55:26 +08:00
richjohnrambo d239bd9ad0 fix: expose friend online status 2026-09-08 10:35:42 +08:00
richjohnrambo b295170eee Accept numeric favorite sequence 2026-09-08 10:26:30 +08:00
richjohnrambo 402dfb0d5a feat: 增加群聊消息查询条件 2026-09-07 21:52:55 +08:00
richjohnrambo 8ff9160113 Customize forward bundle titles 2026-09-07 19:57:36 +08:00
richjohnrambo 8a9e202b56 Preserve uploaded file extensions 2026-09-07 16:13:20 +08:00
richjohnrambo 35da3025a3 fix: 恢复IM内部接口默认启动配置 2026-09-07 16:02:56 +08:00
richjohnrambo 654cd93836 Fix meeting note detail payload 2026-09-07 15:13:44 +08:00
richjohnrambo 7e8fc8237b fix im p0 backend issues 2026-09-07 15:12:14 +08:00
57 changed files with 3421 additions and 280 deletions
-5
View File
@@ -41,12 +41,7 @@ jobs:
API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
UID_ENCRYPTION_KEY: ${{ secrets.UID_ENCRYPTION_KEY }}
INTERNAL_API_ENABLED: "true"
INTERNAL_API_TIME_WINDOW_SECONDS: "300"
INTERNAL_API_HOTGO_SERVICE_NAME: hotgo-admin
ADMIN_INTERNAL_API_BASE_URL: ${{ secrets.ADMIN_INTERNAL_API_BASE_URL }}
ADMIN_INTERNAL_API_ENABLED: "true"
ADMIN_INTERNAL_API_SERVICE_NAME: im-server
ADMIN_INTERNAL_API_SECRET: ${{ secrets.ADMIN_INTERNAL_API_SECRET }}
INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }}
run: |
+3 -3
View File
@@ -114,7 +114,7 @@ ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=d
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
# Default handler for large files
ENV MEDIA_HANDLER=fs
ENV MEDIA_HANDLER=sys_config
# Whitelisted domains for file and S3 large media handler.
ENV FS_CORS_ORIGINS='["*"]'
@@ -223,9 +223,9 @@ RUN mkdir /botdata
RUN chmod +x entrypoint.sh
RUN chmod +x credentials.sh
# Healthcheck: check if port 6060 is open
# Healthcheck: verify the process and database dependency are responsive.
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD nc -z localhost 6060 || exit 1
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
# Generate config from template and run the server.
ENTRYPOINT ["./entrypoint.sh"]
+2 -2
View File
@@ -91,7 +91,7 @@ ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# DB routing resolves to the same tinode DB for the seeded "default" tenant.
ENV TENANT_REGISTRY_DB=hotgo
ENV MEDIA_HANDLER=fs
ENV MEDIA_HANDLER=sys_config
ENV FS_CORS_ORIGINS='["*"]'
ENV AWS_CORS_ORIGINS='["*"]'
ENV SMTP_HOST_URL='http://localhost:6060'
@@ -123,7 +123,7 @@ RUN mkdir -p /botdata static \
&& chmod +x entrypoint.sh credentials.sh tinode init-db apply-hotgo-tenant-db
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD nc -z localhost 6060 || exit 1
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
ENTRYPOINT ["./entrypoint.sh"]
+7 -4
View File
@@ -34,6 +34,9 @@
"presign_ttl": 3600,
"cache_control": "max-age=86400",
"cors_origins": $AWS_CORS_ORIGINS
},
"oss": {
"use_sys_config": true
}
}
},
@@ -151,17 +154,17 @@
}
},
"internal_api": {
"enabled": $INTERNAL_API_ENABLED,
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS,
"enabled": true,
"time_window_seconds": 300,
"services": {
"$INTERNAL_API_HOTGO_SERVICE_NAME": {
"hotgo-admin": {
"secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"]
}
}
},
"admin_internal_api": {
"enabled": $ADMIN_INTERNAL_API_ENABLED,
"enabled": true,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET",
+1
View File
@@ -2439,6 +2439,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。
- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png
+2 -2
View File
@@ -508,11 +508,11 @@ end_reason, created_at, updated_at
### 14.1 个人资料
基础展示资料可以同步到 User Public,但可索引、可审计或敏感字段应进入独立表。
个人资料以 `im_user_profiles` 为唯一业务真源;User Public 与 `im_subscriptions.public` 仅作为 MeetingIM 协议展示投影/缓存,由服务端在资料变更后同步刷新,业务接口不得把这些投影当作可写真源。
| 字段 | 存储建议 |
| --- | --- |
| 头像、昵称、签名 | User Public + `im_user_profiles` |
| 头像、昵称、签名 | `im_user_profiles`;User Public / Subscription Public 仅保留服务端生成的展示投影 |
| 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 |
| 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 |
| 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 |
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS im_user_ip_whitelists (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
ip_cidr VARCHAR(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL DEFAULT 0,
deleted_at DATETIME(3) DEFAULT NULL,
deleted_by BIGINT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_user_ip_whitelist (user_id, ip_cidr),
KEY idx_im_user_ip_whitelist_user (user_id, status),
KEY idx_im_user_ip_whitelist_deleted (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Per-user login IP whitelist';
@@ -125,6 +125,11 @@ SELECT '字符串 UID', 'string', 'string', 'im_rtc_uid_mode', 'info', 0, 20, '
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_uid_mode' AND `value` = 'string');
-- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '媒体存储 Handler', 'string', 'media_handler', 'oss', 'oss', 9, 'IM 文件存储 handler;生产使用 oss,配置文件可使用 sys_config 间接读取', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'media_handler');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 启用状态', 'bool', 'oss_enabled', '0', '0', 10, '是否启用阿里云 OSS 作为 IM 文件存储', 1, 1, NOW(), NOW()
@@ -165,6 +170,26 @@ INSERT INTO hg_sys_config
SELECT 'im_third_party', 'OSS 签名 URL TTL', 'int', 'oss_presign_ttl', '120', '120', 17, '签名下载 URL 有效秒数', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_presign_ttl');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 文件服务 URL 前缀', 'string', 'oss_serve_url', '/v0/file/s/', '/v0/file/s/', 18, 'IM 文件下载接口前缀,OSS handler 用于解析文件 ID 和返回签名跳转', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_serve_url');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 下载缓存头', 'string', 'oss_cache_control', 'no-cache, must-revalidate', 'no-cache, must-revalidate', 19, 'OSS 签名下载响应 Cache-Control', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_cache_control');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS CORS 来源', 'string', 'oss_cors_origins', '["*"]', '["*"]', 20, 'JSON 数组或逗号分隔列表', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_cors_origins');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', 'OSS 启动校验 Bucket', 'bool', 'oss_verify_bucket', '0', '0', 21, '启动时是否调用 OSS 校验 Bucket 存在;生产可按需开启', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_verify_bucket');
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT 'im_third_party', '文件内容安全启用状态', 'bool', 'file_review_content_enabled', '0', '0', 30, '是否启用阿里云内容安全增强版文件审核', 1, 1, NOW(), NOW()
+36 -2
View File
@@ -34,6 +34,18 @@ type adminInternalClient struct {
http *http.Client
}
type tenantLoginIPWhitelistConfig struct {
TenantID types.TenantID `json:"tenant_id"`
AppList []string `json:"app_list"`
WebList []string `json:"web_list"`
}
type tenantLoginIPWhitelistProvider interface {
TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error)
}
var loginIPWhitelistProvider tenantLoginIPWhitelistProvider
type adminInternalTenantDTO struct {
ID types.TenantID `json:"id"`
Code string `json:"code"`
@@ -50,6 +62,7 @@ type adminInternalTenantDTO struct {
func initAdminInternalAPI(raw json.RawMessage) error {
store.SetAdminInternalConfigProvider(nil)
loginIPWhitelistProvider = nil
if len(raw) == 0 || string(raw) == "null" {
return nil
}
@@ -71,10 +84,12 @@ func initAdminInternalAPI(raw json.RawMessage) error {
if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" {
return errors.New("admin_internal_api enabled but base_url or secret is empty")
}
store.SetAdminInternalConfigProvider(&adminInternalClient{
client := &adminInternalClient{
config: cfg,
http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond},
})
}
store.SetAdminInternalConfigProvider(client)
loginIPWhitelistProvider = client
logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName)
return nil
}
@@ -152,6 +167,25 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma
return wrapper.Values, nil
}
func (c *adminInternalClient) TenantLoginIPWhitelist(id types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
var result tenantLoginIPWhitelistConfig
path := "/internal/im-config/tenants/by-id/" + strconv.FormatInt(int64(id), 10) + "/login-ip-whitelist"
if err := c.getJSON(path, nil, &result); err != nil {
return nil, err
}
result.TenantID = id
if result.AppList == nil {
result.AppList = []string{}
}
if result.WebList == nil {
result.WebList = []string{}
}
return &result, nil
}
func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
if c == nil || c.http == nil {
return types.ErrUnsupported
+122
View File
@@ -4,8 +4,10 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/auth"
@@ -14,6 +16,18 @@ import (
"github.com/tinode/chat/server/store/types"
)
const tenantLoginIPWhitelistCacheTTL = 30 * time.Second
type tenantLoginIPWhitelistCacheEntry struct {
config tenantLoginIPWhitelistConfig
expiresAt time.Time
}
var tenantLoginIPWhitelistCache = struct {
sync.RWMutex
items map[types.TenantID]tenantLoginIPWhitelistCacheEntry
}{items: make(map[types.TenantID]tenantLoginIPWhitelistCacheEntry)}
func recordSessionLoginSuccess(s *Session, rec *auth.Rec, scheme, suppliedAccount, requestID string, at time.Time) {
if s == nil || rec == nil || rec.Uid.IsZero() || !s.tenantID.IsValid() {
return
@@ -70,6 +84,114 @@ func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *au
}
}
func checkUserLoginIPWhitelist(tenantID types.TenantID, uid types.Uid, remoteAddr string) error {
result, err := store.InternalAdminQueries.Query(tenantID,
"SELECT ip_cidr FROM im_user_ip_whitelists WHERE user_id=? AND status=1 AND deleted_at IS NULL ORDER BY id",
"SELECT COUNT(*) FROM im_user_ip_whitelists WHERE user_id=? AND status=1 AND deleted_at IS NULL",
[]any{store.DecodeUid(uid)}, 1, 500)
if err != nil {
if errors.Is(err, types.ErrUnsupported) {
return nil
}
logs.Warn.Printf("login ip whitelist user lookup failed tenant:%d uid:%s err:%v", tenantID, uid, err)
return nil
}
if result == nil || len(result.Items) == 0 {
return nil
}
ipText := sessionAuditIP(strings.Split(remoteAddr, ",")[0])
ip := net.ParseIP(ipText)
if ip == nil {
return types.ErrPermissionDenied
}
for _, item := range result.Items {
value := strings.TrimSpace(fmt.Sprint(item["ip_cidr"]))
if allowedIP := net.ParseIP(value); allowedIP != nil && allowedIP.Equal(ip) {
return nil
}
if _, block, parseErr := net.ParseCIDR(value); parseErr == nil && block.Contains(ip) {
return nil
}
}
return types.ErrPermissionDenied
}
func checkTenantLoginIPWhitelist(tenantID types.TenantID, platform, remoteAddr string) error {
config, err := loadTenantLoginIPWhitelist(tenantID)
if err != nil {
logs.Warn.Printf("login ip whitelist tenant lookup failed tenant:%d platform:%s err:%v", tenantID, platform, err)
return nil
}
if config == nil {
return nil
}
values := config.AppList
if tenantLoginIPChannel(platform) == "web" {
values = config.WebList
}
if len(values) == 0 {
return nil
}
if whitelistContainsIP(values, sessionAuditIP(strings.Split(remoteAddr, ",")[0])) {
return nil
}
return types.ErrPermissionDenied
}
func loadTenantLoginIPWhitelist(tenantID types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
if loginIPWhitelistProvider == nil {
return nil, nil
}
now := time.Now()
tenantLoginIPWhitelistCache.RLock()
cached, ok := tenantLoginIPWhitelistCache.items[tenantID]
tenantLoginIPWhitelistCache.RUnlock()
if ok && now.Before(cached.expiresAt) {
result := cached.config
return &result, nil
}
config, err := loginIPWhitelistProvider.TenantLoginIPWhitelist(tenantID)
if err != nil {
if ok {
result := cached.config
return &result, nil
}
config = &tenantLoginIPWhitelistConfig{TenantID: tenantID, AppList: []string{}, WebList: []string{}}
}
if config == nil {
config = &tenantLoginIPWhitelistConfig{TenantID: tenantID, AppList: []string{}, WebList: []string{}}
}
tenantLoginIPWhitelistCache.Lock()
tenantLoginIPWhitelistCache.items[tenantID] = tenantLoginIPWhitelistCacheEntry{config: *config, expiresAt: now.Add(tenantLoginIPWhitelistCacheTTL)}
tenantLoginIPWhitelistCache.Unlock()
result := *config
return &result, nil
}
func tenantLoginIPChannel(platform string) string {
if strings.EqualFold(strings.TrimSpace(platform), "web") {
return "web"
}
return "app"
}
func whitelistContainsIP(values []string, ipText string) bool {
ip := net.ParseIP(strings.TrimSpace(ipText))
if ip == nil {
return false
}
for _, value := range values {
value = strings.TrimSpace(value)
if allowedIP := net.ParseIP(value); allowedIP != nil && allowedIP.Equal(ip) {
return true
}
if _, block, err := net.ParseCIDR(value); err == nil && block.Contains(ip) {
return true
}
}
return false
}
func loginAuditAccount(scheme, supplied, credential string) (string, string) {
if parts := strings.SplitN(strings.TrimSpace(credential), ":", 2); len(parts) == 2 && parts[1] != "" {
kind := strings.ToLower(strings.TrimSpace(parts[0]))
+49
View File
@@ -2,6 +2,8 @@ package main
import (
"errors"
"net/http/httptest"
"reflect"
"strings"
"testing"
@@ -75,6 +77,53 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
}
}
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil)
query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "")
if err != nil {
t.Fatal(err)
}
for _, clause := range []string{"m.seqid = ?", "u.username LIKE ?", "p.nickname LIKE ?", "gs.name LIKE ?"} {
if !strings.Contains(query, clause) {
t.Errorf("group message query missing %q: %s", clause, query)
}
}
want := []any{int64(42), "%alice%", "%小明%", "%测试群%"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
}
}
func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202", nil)
query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "")
if err != nil {
t.Fatal(err)
}
for _, clause := range []string{"CONCAT_WS(' ',u.username,p.nickname,u.phone) LIKE ?", "u.id IN (?,?)"} {
if !strings.Contains(query, clause) || !strings.Contains(countQuery, clause) {
t.Fatalf("user query missing %q: %s", clause, query)
}
}
want := []any{int64(101), int64(202), "%alice%"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
}
for _, field := range []string{
"AS avatar",
"AS register_type",
"im_registration_invite_uses",
"im_fileuploads AS avatar",
"JSON_EXTRACT(u.public,'$.avatar')",
"JSON_EXTRACT(u.public,'$.photo.data')",
"FROM im_org_members AS om",
} {
if !strings.Contains(usersInternalQuerySpec().Select+usersInternalQuerySpec().From, field) {
t.Fatalf("user query missing list field %q", field)
}
}
}
func TestRedactClientMessageForLog(t *testing.T) {
raw := []byte(`{"login":{"id":"1","scheme":"basic","secret":"YWxpY2U6YWxpY2UxMjM="},"acc":{"secret":"account-secret","tmpsecret":"temp-secret"}}`)
redacted := string(redactClientMessageForLog(raw))
+137 -4
View File
@@ -109,10 +109,18 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
if state != "started" {
return nil, nil, types.ErrMalformed
}
call, ok := content.(map[string]any)
if !ok {
if typed, typedOK := content.(types.KVMap); typedOK {
call = map[string]any(typed)
call := callInvitePayloadMap(content)
if len(call) == 0 || !hasCallIdentity(call) {
headCall := callInvitePayloadMap(head)
if len(call) == 0 {
call = headCall
} else {
for key, value := range headCall {
if _, exists := call[key]; !exists {
call[key] = value
}
}
normalizeCallPayloadAliases(call)
}
}
if len(call) == 0 {
@@ -123,6 +131,16 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
if callType == "" {
callType, _ = call["media_type"].(string)
}
if callType == "" {
callType, _ = call["type"].(string)
}
if callType == "" {
if aonly, _ := call["aonly"].(bool); aonly {
callType = constCallTypeAudio
} else {
callType = constCallTypeVideo
}
}
callType = strings.ToLower(strings.TrimSpace(callType))
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
return nil, nil, types.ErrMalformed
@@ -147,6 +165,121 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
return normalizedHead, normalizedContent, nil
}
func callInvitePayloadMap(content any) map[string]any {
call := anyMap(content)
if len(call) == 0 {
return nil
}
normalizeCallPayloadAliases(call)
if hasCallIdentity(call) {
return call
}
if draftyCall := draftyVideoCallPayload(call); len(draftyCall) > 0 {
for key, value := range call {
if _, exists := draftyCall[key]; !exists && key != "fmt" && key != "ent" && key != "txt" {
draftyCall[key] = value
}
}
normalizeCallPayloadAliases(draftyCall)
return draftyCall
}
return call
}
func anyMap(value any) map[string]any {
if typed, ok := value.(map[string]any); ok {
return typed
}
if typed, ok := value.(types.KVMap); ok {
return map[string]any(typed)
}
return nil
}
func normalizeCallPayloadAliases(call map[string]any) {
if call == nil {
return
}
if _, ok := call["callId"]; !ok {
for _, key := range []string{"call_id", "id"} {
if value, exists := call[key]; exists {
call["callId"] = value
break
}
}
}
if _, ok := call["callType"]; !ok {
for _, key := range []string{"call_type", "mediaType", "media_type"} {
if value, exists := call[key]; exists {
call["callType"] = value
break
}
}
}
}
func hasCallIdentity(call map[string]any) bool {
if call == nil {
return false
}
callID, _ := call["callId"].(string)
if strings.TrimSpace(callID) == "" {
return false
}
callType, _ := call["callType"].(string)
if strings.TrimSpace(callType) != "" {
return true
}
if mediaType, _ := call["media_type"].(string); strings.TrimSpace(mediaType) != "" {
return true
}
if _, ok := call["aonly"].(bool); ok {
return true
}
return false
}
func draftyVideoCallPayload(doc map[string]any) map[string]any {
for _, raw := range anySlice(doc["ent"]) {
entity := anyMap(raw)
if entityType, _ := entity["tp"].(string); entityType != "VC" {
continue
}
if data := anyMap(entity["data"]); len(data) > 0 {
return data
}
return map[string]any{}
}
for _, raw := range anySlice(doc["fmt"]) {
format := anyMap(raw)
if formatType, _ := format["tp"].(string); formatType == "VC" {
return map[string]any{}
}
}
return nil
}
func anySlice(value any) []any {
switch typed := value.(type) {
case []any:
return typed
case []map[string]any:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items
case []types.KVMap:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items
default:
return nil
}
}
func callPayloadString(payload any, key string) string {
data, ok := payload.(map[string]any)
if !ok {
+75 -14
View File
@@ -149,7 +149,7 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
dsn = cfg.FormatDSN()
dbName = cfg.DBName
}
db, err := sqlx.Open("mysql", dsn)
db, err := sqlx.Open(instrumentedMySQLDriverName, dsn)
if err != nil {
return nil, "", err
}
@@ -165,6 +165,21 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
return db, dbName, nil
}
func (a *adapter) HealthCheck(ctx context.Context) error {
if a.db == nil {
return errors.New("mysql database is not open")
}
if err := a.db.PingContext(ctx); err != nil {
return err
}
if a.registryDB != nil {
if err := a.registryDB.PingContext(ctx); err != nil {
return errors.New("tenant registry mysql: " + err.Error())
}
}
return nil
}
// Open initializes database session
func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.db != nil {
@@ -672,7 +687,7 @@ func (a *adapter) CreateDb(reset bool) error {
// Clear database name
cfg.DBName = ""
a.db, err = sqlx.Open("mysql", cfg.FormatDSN())
a.db, err = sqlx.Open(instrumentedMySQLDriverName, cfg.FormatDSN())
if err != nil {
return err
}
@@ -2490,7 +2505,8 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
var row profileUserRow
err := a.db.GetContext(ctx, &row, `SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil {
@@ -2523,7 +2539,8 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
}
q, uids, _ := sqlx.In(`SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id IN (?) AND u.state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
@@ -3259,7 +3276,8 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) (
// Fetch p2p users and join to p2p subscriptions.
if len(usrq) > 0 {
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?)`
q, args, _ = sqlx.In(q, usrq)
if !keepDeleted {
@@ -3322,8 +3340,11 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
// Fetch all subscribed users. The number of users is not large.
q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.user_id=s.userid
WHERE s.topic=?`
args := []any{topic}
if !keepDeleted {
@@ -3376,18 +3397,21 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
var lastSeen sql.NullTime
var userAgent string
var public, trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
for rows.Next() {
if err = rows.Scan(
&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
&sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
&public, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil {
break
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(common.FromJSON(public))
sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub)
@@ -4982,6 +5006,11 @@ func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
var fd t.FileDef
err := a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE id=?", store.DecodeUid(id))
if err == sql.ErrNoRows {
storageName := id.String32()
err = a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE location=? OR location LIKE ? ORDER BY createdat DESC LIMIT 1", storageName, "%/"+storageName)
}
if err == sql.ErrNoRows {
return nil, nil
}
@@ -5106,16 +5135,27 @@ func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []
}
dids = append(dids, store.DecodeUid(id))
}
var existing int
query, queryArgs, err := sqlx.In("SELECT COUNT(*) FROM im_fileuploads WHERE id IN (?) AND status=?", dids, t.UploadCompleted)
if err != nil {
return err
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
if err = a.db.GetContext(ctx, &existing, a.db.Rebind(query), queryArgs...); err != nil {
return err
}
if existing != len(dids) {
return t.ErrNotFound
}
for _, id := range dids {
// createdat,fileid,[msgid|topic|userid]
args = append(args, now, id, linkId)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
@@ -5159,12 +5199,18 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
var allowed int
err := a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM im_fileuploads AS fu
WHERE fu.id=? AND fu.status=? AND (
WHERE (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND (
fu.userid=?
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.fileid=fu.id AND fml.userid=?
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_friendships AS fs
ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?
WHERE fml.fileid=fu.id AND fml.userid IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s
@@ -5172,6 +5218,12 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_group_members AS gm
ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?
WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m
@@ -5187,6 +5239,13 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
)
)
OR EXISTS (
SELECT 1 FROM im_group_files AS gf
INNER JOIN im_subscriptions AS s
ON s.topic=gf.topic AND s.userid=?
WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active'
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM im_meeting_note_files AS mnf
INNER JOIN im_meetings AS mtg
@@ -5204,7 +5263,9 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
)
)
) LIMIT 1`,
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid),
store.DecodeUid(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend,
store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid),
store.DecodeUid(uid), fid, fid,
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
if err != nil {
return false, err
@@ -54,6 +54,15 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
INDEX idx_im_message_admin_status(action_status, created_at, id),
INDEX idx_im_message_admin_actor(actor_admin_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_user_ip_whitelists (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
ip_cidr VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_by BIGINT NOT NULL DEFAULT 0,
deleted_at DATETIME(3), deleted_by BIGINT,
PRIMARY KEY(id), UNIQUE INDEX uk_im_user_ip_whitelist(user_id,ip_cidr),
INDEX idx_im_user_ip_whitelist_user(user_id,status), INDEX idx_im_user_ip_whitelist_deleted(deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
@@ -13,11 +13,11 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 3 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
if len(exec.statements) != 4 {
t.Fatalf("created %d admin governance tables, want 4", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_message_admin_actions"} {
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_message_admin_actions", "im_user_ip_whitelists"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
+4
View File
@@ -167,6 +167,10 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_events_outbox_event(event_id),
INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')))
WHERE name LIKE 'grp%' AND JSON_VALID(public)
AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.fn')), '') = ''
AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')), '') <> ''`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
+5 -2
View File
@@ -22,8 +22,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 13 {
t.Fatalf("created %d group tables, want 13", len(exec.statements))
if len(exec.statements) != 14 {
t.Fatalf("executed %d group schema statements, want 14", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
@@ -40,4 +40,7 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
t.Fatalf("group schema is missing snowflake primary-key definition %q", expected)
}
}
if !strings.Contains(joined, "json_set(public, '$.fn'") {
t.Fatal("group schema is missing historical public.fn backfill")
}
}
+261
View File
@@ -0,0 +1,261 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"database/sql/driver"
"time"
ms "github.com/go-sql-driver/mysql"
immetrics "github.com/tinode/chat/server/metrics"
)
const instrumentedMySQLDriverName = "meetingim_mysql_metrics"
func init() {
sql.Register(instrumentedMySQLDriverName, instrumentedMySQLDriver{})
}
type instrumentedMySQLDriver struct{}
func (instrumentedMySQLDriver) Open(dsn string) (driver.Conn, error) {
connector, err := instrumentedMySQLDriver{}.OpenConnector(dsn)
if err != nil {
return nil, err
}
return connector.Connect(context.Background())
}
func (instrumentedMySQLDriver) OpenConnector(dsn string) (driver.Connector, error) {
cfg, err := ms.ParseDSN(dsn)
if err != nil {
return nil, err
}
connector, err := ms.NewConnector(cfg)
if err != nil {
return nil, err
}
return instrumentedConnector{connector: connector}, nil
}
type instrumentedConnector struct {
connector driver.Connector
}
func (c instrumentedConnector) Connect(ctx context.Context) (driver.Conn, error) {
conn, err := c.connector.Connect(ctx)
if err != nil {
return nil, err
}
return instrumentedConn{conn: conn}, nil
}
func (c instrumentedConnector) Driver() driver.Driver {
return instrumentedMySQLDriver{}
}
type instrumentedConn struct {
conn driver.Conn
}
func (c instrumentedConn) Prepare(query string) (driver.Stmt, error) {
stmt, err := c.conn.Prepare(query)
if err != nil {
return nil, err
}
return instrumentedStmt{stmt: stmt}, nil
}
func (c instrumentedConn) Close() error {
return c.conn.Close()
}
func (c instrumentedConn) Begin() (driver.Tx, error) {
started := time.Now()
tx, err := c.conn.Begin()
immetrics.ObserveDB("mysql", "begin", started, err)
if err != nil {
return nil, err
}
return instrumentedTx{tx: tx}, nil
}
func (c instrumentedConn) Ping(ctx context.Context) error {
pinger, ok := c.conn.(driver.Pinger)
if !ok {
return driver.ErrSkip
}
started := time.Now()
err := pinger.Ping(ctx)
immetrics.ObserveDB("mysql", "ping", started, err)
return err
}
func (c instrumentedConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
execer, ok := c.conn.(driver.ExecerContext)
if !ok {
return nil, driver.ErrSkip
}
started := time.Now()
result, err := execer.ExecContext(ctx, query, args)
immetrics.ObserveDB("mysql", "exec", started, err)
return result, err
}
func (c instrumentedConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
queryer, ok := c.conn.(driver.QueryerContext)
if !ok {
return nil, driver.ErrSkip
}
started := time.Now()
rows, err := queryer.QueryContext(ctx, query, args)
immetrics.ObserveDB("mysql", "query", started, err)
return rows, err
}
func (c instrumentedConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
beginner, ok := c.conn.(driver.ConnBeginTx)
if !ok {
return nil, driver.ErrSkip
}
started := time.Now()
tx, err := beginner.BeginTx(ctx, opts)
immetrics.ObserveDB("mysql", "begin", started, err)
if err != nil {
return nil, err
}
return instrumentedTx{tx: tx}, nil
}
func (c instrumentedConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
preparer, ok := c.conn.(driver.ConnPrepareContext)
if !ok {
return nil, driver.ErrSkip
}
stmt, err := preparer.PrepareContext(ctx, query)
if err != nil {
return nil, err
}
return instrumentedStmt{stmt: stmt}, nil
}
func (c instrumentedConn) CheckNamedValue(value *driver.NamedValue) error {
checker, ok := c.conn.(driver.NamedValueChecker)
if !ok {
return driver.ErrSkip
}
return checker.CheckNamedValue(value)
}
func (c instrumentedConn) ResetSession(ctx context.Context) error {
resetter, ok := c.conn.(driver.SessionResetter)
if !ok {
return nil
}
return resetter.ResetSession(ctx)
}
func (c instrumentedConn) IsValid() bool {
validator, ok := c.conn.(driver.Validator)
if !ok {
return true
}
return validator.IsValid()
}
type instrumentedStmt struct {
stmt driver.Stmt
}
func (s instrumentedStmt) Close() error {
return s.stmt.Close()
}
func (s instrumentedStmt) NumInput() int {
return s.stmt.NumInput()
}
func (s instrumentedStmt) Exec(args []driver.Value) (driver.Result, error) {
started := time.Now()
result, err := s.stmt.Exec(args)
immetrics.ObserveDB("mysql", "exec", started, err)
return result, err
}
func (s instrumentedStmt) Query(args []driver.Value) (driver.Rows, error) {
started := time.Now()
rows, err := s.stmt.Query(args)
immetrics.ObserveDB("mysql", "query", started, err)
return rows, err
}
func (s instrumentedStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) {
execer, ok := s.stmt.(driver.StmtExecContext)
if !ok {
return nil, driver.ErrSkip
}
started := time.Now()
result, err := execer.ExecContext(ctx, args)
immetrics.ObserveDB("mysql", "exec", started, err)
return result, err
}
func (s instrumentedStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) {
queryer, ok := s.stmt.(driver.StmtQueryContext)
if !ok {
return nil, driver.ErrSkip
}
started := time.Now()
rows, err := queryer.QueryContext(ctx, args)
immetrics.ObserveDB("mysql", "query", started, err)
return rows, err
}
func (s instrumentedStmt) CheckNamedValue(value *driver.NamedValue) error {
checker, ok := s.stmt.(driver.NamedValueChecker)
if !ok {
return driver.ErrSkip
}
return checker.CheckNamedValue(value)
}
type instrumentedTx struct {
tx driver.Tx
}
func (tx instrumentedTx) Commit() error {
started := time.Now()
err := tx.tx.Commit()
immetrics.ObserveDB("mysql", "commit", started, err)
return err
}
func (tx instrumentedTx) Rollback() error {
started := time.Now()
err := tx.tx.Rollback()
immetrics.ObserveDB("mysql", "rollback", started, err)
return err
}
var (
_ driver.Driver = instrumentedMySQLDriver{}
_ driver.DriverContext = instrumentedMySQLDriver{}
_ driver.Connector = instrumentedConnector{}
_ driver.Conn = instrumentedConn{}
_ driver.Pinger = instrumentedConn{}
_ driver.ExecerContext = instrumentedConn{}
_ driver.QueryerContext = instrumentedConn{}
_ driver.ConnBeginTx = instrumentedConn{}
_ driver.ConnPrepareContext = instrumentedConn{}
_ driver.NamedValueChecker = instrumentedConn{}
_ driver.SessionResetter = instrumentedConn{}
_ driver.Validator = instrumentedConn{}
_ driver.Stmt = instrumentedStmt{}
_ driver.StmtExecContext = instrumentedStmt{}
_ driver.StmtQueryContext = instrumentedStmt{}
_ driver.NamedValueChecker = instrumentedStmt{}
_ driver.Tx = instrumentedTx{}
)
@@ -54,3 +54,21 @@ func TestMessageSearchDoesNotClearScannedHead(t *testing.T) {
t.Fatal("message search must not clear Head after sqlx StructScan has already decoded it")
}
}
func TestTenantFileAccessIncludesAvatarAudienceRules(t *testing.T) {
source, err := os.ReadFile("tenant_adapter.go")
if err != nil {
t.Fatal(err)
}
body := string(source)
for _, want := range []string{
"INNER JOIN im_friendships AS fs",
"fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?",
"INNER JOIN im_group_members AS gm",
"gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?",
} {
if !strings.Contains(body, want) {
t.Fatalf("file ACL must include avatar audience rule %q", want)
}
}
}
+106 -17
View File
@@ -63,6 +63,10 @@ type profileUserRow struct {
Trusted any `db:"trusted"`
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}
@@ -81,7 +85,7 @@ func (row profileUserRow) lastSeen() *time.Time {
}
func (row profileUserRow) profilePublic() any {
return profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID)
return profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
}
func profileUserFromRow(row profileUserRow) t.User {
@@ -106,7 +110,7 @@ func profileUserFromRow(row profileUserRow) t.User {
return user
}
func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) any {
func profilePublicFromFields(base any, nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any {
public := publicMap(base)
clearProfilePublicFields(public)
if nickname.Valid {
@@ -123,11 +127,31 @@ func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) an
public["avatar_file_id"] = value
}
}
if signature.Valid {
if value := strings.TrimSpace(signature.String); value != "" {
public["signature"] = value
}
}
if gender.Valid {
if value := strings.TrimSpace(gender.String); value != "" {
public["gender"] = value
}
}
if region.Valid {
if value := strings.TrimSpace(region.String); value != "" {
public["region"] = value
}
}
if publicAccount.Valid {
if value := strings.TrimSpace(publicAccount.String); value != "" {
public["public_account"] = value
}
}
return public
}
func clearProfilePublicFields(public map[string]any) {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar"} {
for _, key := range []string{"display_name", "nickname", "fn", "name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
delete(public, key)
}
}
@@ -335,7 +359,8 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
var row profileUserRow
err = a.db.GetContext(ctx, &row, `SELECT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`,
id, decodeUID(uid), t.StateDeleted)
@@ -995,7 +1020,8 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool
}
if len(usrq) > 0 {
q = `SELECT u.tenant_id,u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.tenant_id=? AND u.id IN (?)`
q, args, _ = sqlx.In(q, id, usrq)
if !keepDeleted {
@@ -1051,8 +1077,11 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
}
tcat := t.GetTopicCat(topic)
q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid
WHERE s.tenant_id=? AND s.topic=?`
args := []any{id, topic}
if !keepDeleted {
@@ -1093,14 +1122,17 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
var sub t.Subscription
var lastSeen sql.NullTime
var public, trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
&public, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil {
return nil, err
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(common.FromJSON(public))
sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub)
@@ -7025,15 +7057,17 @@ func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content s
return nil, err
}
now := t.TimeNow()
_, err = a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts
res, err := a.db.ExecContext(ctx, `INSERT INTO im_topic_drafts
(id,user_id,topic,content,entities_json,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),entities_json=VALUES(entities_json),
version=version+1,updated_at=VALUES(updated_at)`,
ON DUPLICATE KEY UPDATE id=id`,
draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
}
res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts
@@ -7656,6 +7690,13 @@ func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) {
err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID))
if err == sql.ErrNoRows {
storageName := fileID.String32()
err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+
"FROM im_fileuploads WHERE tenant_id=? AND (location=? OR location LIKE ?) ORDER BY createdat DESC LIMIT 1",
id, storageName, "%/"+storageName)
}
if err == sql.ErrNoRows {
return nil, nil
}
@@ -7760,17 +7801,31 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID,
fids = fids[:1]
}
var args []any
var dids []any
for _, fid := range fids {
fileID := t.ParseUid(fid)
if fileID.IsZero() {
return t.ErrMalformed
}
args = append(args, id, now, decodeUID(fileID), linkID)
decoded := decodeUID(fileID)
dids = append(dids, decoded)
args = append(args, id, now, decoded, linkID)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
var existing int
query, queryArgs, err := sqlx.In("SELECT COUNT(*) FROM im_fileuploads WHERE tenant_id=? AND id IN (?) AND status=?", id, dids, t.UploadCompleted)
if err != nil {
return err
}
if err = a.db.GetContext(ctx, &existing, a.db.Rebind(query), queryArgs...); err != nil {
return err
}
if existing != len(dids) {
return t.ErrNotFound
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
@@ -7815,12 +7870,18 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
var allowed int
err = a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM im_fileuploads AS fu
WHERE fu.tenant_id=? AND fu.id=? AND fu.status=? AND (
WHERE fu.tenant_id=? AND (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND (
fu.userid=?
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=?
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_friendships AS fs
ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s
@@ -7828,6 +7889,12 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_group_members AS gm
ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=?
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL
)
OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m
@@ -7843,8 +7910,17 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
)
)
OR EXISTS (
SELECT 1 FROM im_group_files AS gf
INNER JOIN im_subscriptions AS s
ON s.tenant_id=fu.tenant_id AND s.topic=gf.topic AND s.userid=?
WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active'
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
)
) LIMIT 1`,
id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid))
id, decodeUID(fileID), fileID.String32(), "%/"+fileID.String32(), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), t.RelationshipFriend,
decodeUID(uid), decodeUID(uid), t.GroupMemberStatusActive, decodeUID(uid), decodeUID(uid),
decodeUID(uid), fid, fid)
if err != nil {
return false, err
}
@@ -9163,6 +9239,13 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
row.HandledAt = &handledAt
row.UpdatedAt = handledAt
}
if _, err = tx.ExecContext(ctx, `UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=?
WHERE request_id<>? AND status=? AND
((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?))`,
t.FriendRequestExpired, handledAt, handledAt, requestID, t.FriendRequestPending,
decodeUID(applicant), decodeUID(target), decodeUID(target), decodeUID(applicant)); err != nil {
return nil, err
}
for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
friendshipID, idErr := nextSnowflakeID()
if idErr != nil {
@@ -9389,7 +9472,8 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.updated_at AS profile_updated_at
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM im_friendships AS fs
JOIN im_users AS u ON u.id=fs.peer_id
LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
@@ -9415,6 +9499,10 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}{}
if err = rows.StructScan(&row); err != nil {
@@ -9439,7 +9527,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id,
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID),
Public: profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount),
Trusted: common.FromJSON(row.Trusted),
},
})
@@ -9791,7 +9879,8 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT ? AS tenant_id,u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted,
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.updated_at AS profile_updated_at
profile.nickname AS profile_nickname,profile.avatar_file_id AS profile_avatar_file_id,profile.signature AS profile_signature,
profile.gender AS profile_gender,profile.region AS profile_region,profile.public_account AS profile_public_account,profile.updated_at AS profile_updated_at
FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
+123 -15
View File
@@ -248,30 +248,49 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
if opts.Limit <= 0 || opts.Limit > 100 {
opts.Limit = 30
}
where := []string{"topic=?", "member_status=?"}
memberIndex := "idx_im_group_members_topic_status"
where := []string{"gm.topic=?", "gm.member_status=?"}
args := []any{topic, t.GroupMemberStatusActive}
if opts.Role != "" {
where = append(where, "role=?")
memberIndex = "idx_im_group_members_topic_role"
where = append(where, "gm.role=?")
args = append(args, opts.Role)
}
if opts.Query != "" {
where = append(where, "nickname LIKE ?")
args = append(args, "%"+opts.Query+"%")
like := "%" + opts.Query + "%"
where = append(where, `(gm.nickname LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ?
OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.fn')) LIKE ?
OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.name')) LIKE ?
OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.display_name')) LIKE ?
OR u.username LIKE ?)`)
args = append(args, like, like, like, like, like, like, like)
}
if opts.Cursor != "" {
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
if err != nil || parsed <= 0 {
if err != nil || parsed < 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id>?")
if parsed > 0 {
where = append(where, "gm.id>?")
args = append(args, parsed)
}
}
if opts.Mentionable {
where = append(where, "INSTR(s.modewant,'J')>0 AND INSTR(s.modegiven,'J')>0")
}
args = append(args, opts.Limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_group_members WHERE "+strings.Join(where, " AND ")+" ORDER BY id LIMIT ?", args...)
rows, err := a.db.QueryxContext(ctx, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`)
JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL
JOIN im_users AS u ON u.id=gm.user_id AND u.state!=?
LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id
WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`, append([]any{t.StateDeleted}, args...)...)
if err != nil {
return nil, "", err
}
@@ -279,15 +298,30 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
items := make([]t.GroupMember, 0, opts.Limit)
next := ""
for rows.Next() {
var item t.GroupMember
if err = rows.StructScan(&item); err != nil {
row := struct {
t.GroupMember
Public any
Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"`
ProfilePublicAccount sql.NullString `db:"profile_public_account"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
item := row.GroupMember
scanGroupMember(&item)
if len(items) == opts.Limit {
next = strconv.FormatInt(item.ID, 10)
if len(items) > 0 {
next = strconv.FormatInt(items[len(items)-1].ID, 10)
}
continue
}
item.Public = profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
item.Trusted = common.FromJSON(row.Trusted)
items = append(items, item)
}
return items, next, rows.Err()
@@ -445,7 +479,8 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
return &inv, nil
}
var deletedAt sql.NullTime
err = tx.QueryRowxContext(ctx, "SELECT DeletedAt FROM subscriptions WHERE Topic=? AND User=?", inv.Topic, uid.String()).Scan(&deletedAt)
err = tx.QueryRowxContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
inv.Topic, decodeUID(uid)).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
@@ -906,6 +941,7 @@ func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cur
next = strconv.FormatInt(file.ID, 10)
continue
}
a.enrichGroupFileFromUpload(id, file)
items = append(items, *file)
}
return items, next, rows.Err()
@@ -932,21 +968,93 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
if file.IdempotencyKey == "" {
file.IdempotencyKey = file.GroupFileID
}
a.enrichGroupFileFromUpload(id, file)
now := t.TimeNow()
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_files
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_files
(group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
ON DUPLICATE KEY UPDATE
file_name=IF(file_name='', VALUES(file_name), file_name),
mime_type=IF(mime_type='', VALUES(mime_type), mime_type),
file_size=IF(file_size=0, VALUES(file_size), file_size),
file_type=IF(file_type='' OR file_type='file', VALUES(file_type), file_type),
updated_at=updated_at`,
file.GroupFileID, file.Topic, file.FileID, file.SourceTopic, file.SourceSeq, decodeUID(file.UploaderID),
file.FileName, file.MIMEType, file.FileType, file.FileSize, file.Status, file.IdempotencyKey, now, now)
if err != nil && !isDupe(err) {
return nil, err
}
return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID)
uploadID := strings.TrimSpace(file.FileID)
if dot := strings.IndexByte(uploadID, '.'); dot > 0 {
uploadID = uploadID[:dot]
}
if fid := t.ParseUid(uploadID); !fid.IsZero() {
if a.tenantScopedDB {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(createdat,fileid,topic)
SELECT ?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE fileid=? AND topic=?)`,
now, decodeUID(fid), file.Topic, decodeUID(fid), file.Topic)
} else {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(tenant_id,createdat,fileid,topic)
SELECT ?,?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`,
id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic)
}
if err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
created, err := a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID)
if err != nil {
return nil, err
}
a.enrichGroupFileFromUpload(id, created)
return created, nil
}
func (a *adapter) enrichGroupFileFromUpload(id t.TenantID, file *t.GroupFile) {
if file == nil {
return
}
fileID := strings.TrimSpace(file.FileID)
if dot := strings.IndexByte(fileID, '.'); dot > 0 {
fileID = fileID[:dot]
}
if t.ParseUid(fileID).IsZero() {
return
}
fd, err := a.TenantFileGet(id, fileID)
if err != nil || fd == nil {
if file.FileName == "" {
file.FileName = file.FileID
}
return
}
if file.MIMEType == "" {
file.MIMEType = strings.TrimSpace(fd.MimeType)
}
if file.FileSize <= 0 {
file.FileSize = fd.Size
}
if file.FileName == "" {
if original := strings.TrimSpace(fd.OriginalName); original != "" {
file.FileName = original
} else {
file.FileName = file.FileID
}
}
}
func (a *adapter) TenantGroupFileDelete(id t.TenantID, topic, groupFileID string, actor t.Uid, canManage bool, now time.Time) (*t.GroupFile, error) {
+42
View File
@@ -111,6 +111,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
if err = backfillProfileDisplay(ctx, conn, usersScoped); err != nil {
return err
}
if err = backfillUserPublicFromProfile(ctx, conn, usersScoped); err != nil {
return err
}
}
indexName := "users_phone"
@@ -464,6 +467,45 @@ func backfillProfileDisplay(ctx context.Context, conn *sqlx.Conn, usersScoped bo
return err
}
func backfillUserPublicFromProfile(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error {
profilesScoped, err := identityColumnExists(ctx, conn, "im_user_profiles", "tenant_id")
if err != nil {
return err
}
if profilesScoped != usersScoped {
return errors.New("mysql: im_users and im_user_profiles tenant scope mismatch")
}
publicDoc := "COALESCE(NULLIF(u.public,''), JSON_OBJECT())"
nicknamePatch := `u.public=JSON_SET(` + publicDoc + `,
'$.display_name',p.nickname,'$.nickname',p.nickname,'$.fn',p.nickname,'$.name',p.nickname)`
avatarPatch := `u.public=JSON_SET(` + publicDoc + `,'$.avatar_file_id',p.avatar_file_id)`
accountPatch := `u.public=JSON_SET(` + publicDoc + `,'$.public_account',p.public_account)`
join := "JOIN im_users AS u ON u.id=p.user_id"
if usersScoped {
join = "JOIN im_users AS u ON u.tenant_id=p.tenant_id AND u.id=p.user_id"
}
for _, query := range []string{
`UPDATE im_user_profiles AS p ` + join + `
SET ` + nicknamePatch + `
WHERE p.nickname<>'' AND (
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.display_name')),'')<>p.nickname OR
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.nickname')),'')<>p.nickname OR
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.fn')),'')<>p.nickname OR
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.name')),'')<>p.nickname)`,
`UPDATE im_user_profiles AS p ` + join + `
SET ` + avatarPatch + `
WHERE p.avatar_file_id<>'' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')),'')<>p.avatar_file_id`,
`UPDATE im_user_profiles AS p ` + join + `
SET ` + accountPatch + `
WHERE p.public_account<>'' AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.public_account')),'')<>p.public_account`,
} {
if _, err = conn.ExecContext(ctx, query); err != nil {
return err
}
}
return nil
}
func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
var out []identityValue
appendQuery := func(query string, args ...any) error {
+12 -7
View File
@@ -34,16 +34,22 @@ func TestProfilePublicFromFields(t *testing.T) {
"nickname": "旧昵称",
"display_name": "旧昵称",
"avatar_file_id": "old_avatar",
"signature": "旧签名",
"gender": "old_gender",
"region": "old_region",
"photo": "old_photo",
"public_account": "usrAccount",
}, sql.NullString{String: "新昵称", Valid: true}, sql.NullString{String: "file_avatar_1", Valid: true}).(map[string]any)
}, sql.NullString{String: "新昵称", Valid: true}, sql.NullString{String: "file_avatar_1", Valid: true},
sql.NullString{String: "新签名", Valid: true}, sql.NullString{String: "male", Valid: true},
sql.NullString{String: "上海", Valid: true}, sql.NullString{String: "usrProfile", Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name"} {
if public[key] != "新昵称" {
t.Fatalf("nickname field %s not overlaid: %#v", key, public)
}
}
if public["avatar_file_id"] != "file_avatar_1" || public["public_account"] != "usrAccount" {
if public["avatar_file_id"] != "file_avatar_1" || public["signature"] != "新签名" || public["gender"] != "male" ||
public["region"] != "上海" || public["public_account"] != "usrProfile" {
t.Fatalf("profile public not overlaid: %#v", public)
}
@@ -51,15 +57,14 @@ func TestProfilePublicFromFields(t *testing.T) {
"fn": "旧昵称",
"avatar_file_id": "old_avatar",
"public_account": "usrAccount",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar"} {
"signature": "旧签名",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true},
sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} {
if _, ok := public[key]; ok {
t.Fatalf("legacy profile field %s should not be read from im_users.public: %#v", key, public)
}
}
if public["public_account"] != "usrAccount" {
t.Fatalf("unrelated public field changed: %#v", public)
}
}
func TestProfileSeedFromPublic(t *testing.T) {
+7 -4
View File
@@ -27,6 +27,7 @@ import (
"github.com/tinode/chat/pbx"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"google.golang.org/grpc/peer"
@@ -310,15 +311,16 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
return
}
mimeType := http.DetectContentType(buff)
detectedMimeType := http.DetectContentType(buff)
clientContentType := ""
// If DetectContentType fails, see if client-provided content type can be used.
if mimeType == "application/octet-stream" {
if detectedMimeType == "application/octet-stream" {
if userContentType, params, err := mime.ParseMediaType(header.Header.Get("Content-Type")); err == nil {
// Make sure the content-type is legit.
for _, allowed := range allowedMimeTypes {
if strings.HasPrefix(userContentType, allowed) {
if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" {
mimeType = userContentType
clientContentType = userContentType
}
break
}
@@ -332,7 +334,8 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
},
TenantID: principal.TenantID,
User: principal.UID.String(),
MimeType: mimeType,
MimeType: media.MimeTypeForUpload(detectedMimeType, clientContentType, header.Filename),
OriginalName: header.Filename,
}
fdef.InitTimes()
+2 -2
View File
@@ -34,7 +34,7 @@ func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (
data, _ := io.ReadAll(file)
h.def = def
h.data = string(data)
def.Location = "/v0/file/s/" + def.Uid().String() + ".txt"
def.Location = "/v0/file/s/" + media.FileURLName(def)
def.ETag = "etag"
return def.Location, int64(len(data)), nil
}
@@ -252,7 +252,7 @@ func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) {
params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" {
t.Fatalf("unexpected upload params: %#v", params)
}
if handler.data != "hello notes" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" {
if handler.data != "hello notes" || handler.def.OriginalName != "notes.txt" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" {
t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes)
}
}
+350 -61
View File
@@ -62,7 +62,7 @@ const (
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imSensitiveChallengeRateLimitEnabled = false
imSensitiveChallengeRateLimitEnabled = true
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
@@ -408,7 +408,7 @@ type imMessageRevisionRequest struct {
type imFavoritesBatchCreateRequest struct {
Messages []struct {
Topic string `json:"topic"`
Seq int64 `json:"seq,string"`
Seq imForwardSeq `json:"seq"`
} `json:"messages"`
}
@@ -1503,7 +1503,7 @@ func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
return
}
action := strings.TrimSpace(body.Scene)
if action == "" || len(action) > 64 || strings.TrimSpace(body.Channel) != "sms" {
if !validIMSecurityChallengeScene(action) || strings.TrimSpace(body.Channel) != "sms" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
@@ -2530,7 +2530,8 @@ func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meeting
meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)})
writeIMSuccess(wrt, req, map[string]any{
"meeting_id": meetingID,
"reminded": meetingParticipantPayloads(participants),
"reminded": meetingParticipantPayloadsWithSummaries(participants,
meetingUserSummaries(principal.TenantID, []types.Meeting{{Participants: participants}})),
})
}
@@ -3109,6 +3110,14 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) {
return
}
}
if body.AvatarFileID != nil {
if avatar := strings.TrimSpace(*body.AvatarFileID); avatar != "" {
if err := store.Files.LinkAttachments(principal.TenantID, principal.Uid.UserId(), types.ZeroUid, []string{avatar}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
}
}
profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
AvatarFileID: body.AvatarFileID,
Nickname: body.Nickname,
@@ -3557,37 +3566,39 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
failed := make([]map[string]any, 0)
for _, src := range body.Messages {
sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic)
if !sourceOK || src.Seq <= 0 {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest})
seq := int64(src.Seq)
if !sourceOK || seq <= 0 {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest})
continue
}
if err := requireTopicReader(principal, sourceTopic); err != nil {
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)})
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
continue
}
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, src.Seq)
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq)
if err != nil {
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": src.Seq, "reason": relationErrorReason(err)})
failed = append(failed, map[string]any{"topic": sourceTopic, "seq": seq, "reason": relationErrorReason(err)})
continue
}
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq)
fav := &types.FavoriteItem{
TenantID: principal.TenantID,
FavoriteID: "fav_" + store.Store.GetUidString(),
UserID: principal.Uid,
ItemType: imMessageType(msg),
SourceTopic: sourceTopic,
SourceSeq: src.Seq,
SourceSeq: seq,
Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: messageAvailability(*msg),
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
IdempotencyKey: idempotencyKey,
CollectedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
item, err := store.Favorites.Create(principal.TenantID, fav)
if err != nil {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)})
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": relationErrorReason(err)})
continue
}
if item.FavoriteID == fav.FavoriteID {
@@ -3696,9 +3707,10 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
comment := strings.TrimSpace(body.Comment)
for _, target := range body.TargetTopics {
target = strings.TrimSpace(target)
if target == "" {
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest})
rawTarget := strings.TrimSpace(target)
target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
if target == "" || !targetOK {
results = append(results, map[string]any{"target_topic": rawTarget, "status": "failed", "reason": imReasonMalformedRequest})
continue
}
if err := requireTopicWriter(principal, target); err != nil {
@@ -3723,6 +3735,10 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
},
Content: favoriteForwardContent(item, comment),
}
if err := prepareHTTPForwardMessage(principal.TenantID, msg); err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
continue
}
saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
@@ -4021,13 +4037,13 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
return
}
principal, _, ok := authenticateTopicReader(wrt, req, topic)
principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
if !ok {
return
}
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
msgs, err := store.Messages.GetAll(principal.TenantID, normalizedTopic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil {
writeIMRelationError(wrt, req, err)
return
@@ -4036,7 +4052,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
for _, msg := range msgs {
items = append(items, messageContextPayload(msg))
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "anchor_seq": seq, "items": items})
writeIMSuccess(wrt, req, map[string]any{"topic": normalizedTopic, "anchor_seq": seq, "items": items})
}
func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) {
@@ -4392,6 +4408,60 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
relation, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, target, principal.Uid.P2PName(target))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if relation == nil || relation.Relationship != types.RelationshipNone {
reason := "FRIEND_REQUEST_CONFLICT"
if relation != nil && (relation.Relationship == types.RelationshipBlockedByMe || relation.Relationship == types.RelationshipBlockedByPeer) {
reason = imReasonPermissionDenied
}
writeIMError(wrt, req, http.StatusConflict, 30006, reason, "friend request is not allowed for current relationship", nil)
return
}
source := strings.TrimSpace(body.Source)
sourceTopic := strings.TrimSpace(body.SourceTopic)
switch source {
case "search", "qr":
if sourceTopic != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil)
return
}
case "group":
if types.GetTopicCat(sourceTopic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "valid group source_topic is required", nil)
return
}
requesterSub, requesterErr := store.Subs.Get(principal.TenantID, sourceTopic, principal.Uid, false)
targetSub, targetErr := store.Subs.Get(principal.TenantID, sourceTopic, target, false)
if requesterErr != nil || targetErr != nil {
if requesterErr != nil {
writeIMRelationError(wrt, req, requesterErr)
} else {
writeIMRelationError(wrt, req, targetErr)
}
return
}
if requesterSub == nil || targetSub == nil ||
!(requesterSub.ModeGiven & requesterSub.ModeWant).IsJoiner() || !(targetSub.ModeGiven & targetSub.ModeWant).IsJoiner() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "group membership required", nil)
return
}
settings, settingsErr := store.GroupBusiness.GetSettings(principal.TenantID, sourceTopic)
if settingsErr != nil {
writeIMRelationError(wrt, req, settingsErr)
return
}
if settings == nil || !settings.MemberPrivateChat {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member private chat is disabled", nil)
return
}
default:
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "invalid friend request source", nil)
return
}
privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
if err != nil {
writeIMRelationError(wrt, req, err)
@@ -4414,8 +4484,8 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
ApplicantID: principal.Uid,
TargetID: target,
Message: message,
Source: strings.TrimSpace(body.Source),
SourceTopic: strings.TrimSpace(body.SourceTopic),
Source: source,
SourceTopic: sourceTopic,
Status: types.FriendRequestPending,
IdempotencyKey: idempotencyKey,
ExpiresAt: now.Add(7 * 24 * time.Hour),
@@ -4461,7 +4531,7 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) {
if item.Friendship.PeerID == principal.Uid {
continue
}
payload = append(payload, friendListItemPayload(item))
payload = append(payload, friendListItemPayload(principal.TenantID, principal.Uid, item))
}
writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor})
}
@@ -4585,6 +4655,12 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
CreatedAt: now,
UpdatedAt: now,
}
if settings.AvatarFileID != "" {
if err := linkIMGroupAvatarAttachment(principal.TenantID, topicName, settings.AvatarFileID); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
return
}
}
memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs))
seen := map[types.Uid]bool{principal.Uid: true}
for _, rawID := range body.MemberUserIDs {
@@ -4732,8 +4808,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor"))
if err != nil {
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
if cursor != "" {
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
}
if parsed, err := parseIMNonNegativeCursor(cursor); err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
@@ -4747,25 +4829,22 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
writeIMSuccess(wrt, req, cached)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{
Role: roleFilter,
EligibleRole: eligibleRole,
Query: query,
Cursor: cursor,
Limit: limit,
Mentionable: mentionableOnly,
})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, member := range members {
access := member.ModeGiven & member.ModeWant
mentionable := access.IsJoiner() && access.IsReader()
role := groupMemberRole(access)
if roleFilter != "" && role != roleFilter {
continue
}
if mentionableOnly && !mentionable {
continue
}
item := groupMemberPayload(member, mentionable)
access := groupMemberAccess(member)
item := groupBusinessMemberPayload(member)
if eligibleRole != "" {
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
item["eligible"] = eligible
@@ -4780,19 +4859,7 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
item["ineligible_reason"] = reason
}
}
if query != "" && !groupMemberMatches(item, query) {
continue
}
if seen < cursor {
seen++
continue
}
if len(items) == limit {
nextCursor = strconv.Itoa(seen)
break
}
items = append(items, item)
seen++
}
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
@@ -4866,7 +4933,14 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
public["fn"] = name
}
if body.AvatarFileID != nil {
public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID)
avatar := strings.TrimSpace(*body.AvatarFileID)
if avatar != "" {
if err := linkIMGroupAvatarAttachment(principal.TenantID, topic, avatar); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "avatar_file_id"})
return
}
}
public["avatar_file_id"] = avatar
}
if body.Description != nil {
description := strings.TrimSpace(*body.Description)
@@ -5016,7 +5090,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil {
if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
if errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId())
dispatchIMGroupEvents(principal.TenantID)
@@ -5107,6 +5181,17 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
return
}
access := selfSub.ModeGiven & selfSub.ModeWant
if !access.IsAdmin() {
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if settings == nil || !settings.MemberInvite {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member invitation is disabled", nil)
return
}
}
if !access.IsSharer() && !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
@@ -5210,6 +5295,17 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
"has_more": nextCursor != "",
})
case tail == "files" && req.Method == http.MethodPost:
if !access.IsAdmin() {
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
if settings == nil || !settings.MemberUploadFiles {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member file upload is disabled", nil)
return
}
}
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok {
return
@@ -5218,7 +5314,7 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
if !decodeIMJSONBody(wrt, req, &body) {
return
}
fileID := strings.TrimSpace(body.FileID)
fileID := normalizeIMUploadedFileID(body.FileID)
if fileID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return
@@ -5322,6 +5418,18 @@ func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
if !settings.JoinApproval {
now := time.Now().UTC().Round(time.Millisecond)
joined, joinErr := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, principal.Uid,
"", strings.TrimSpace(req.Header.Get("Idempotency-Key")), groupMemberPrivate("", false, false, 1, now), now)
if joinErr != nil {
writeIMRelationError(wrt, req, joinErr)
return
}
dispatchIMGroupEvents(principal.TenantID)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "joined", "joined": joined, "source": "open_group"})
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMRelationError(wrt, req, err)
@@ -6815,6 +6923,41 @@ func normalizeIMFileType(raw string) string {
}
}
func normalizeIMUploadedFileID(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if cut, _, ok := strings.Cut(value, "?"); ok {
value = cut
}
if cut, _, ok := strings.Cut(value, "#"); ok {
value = cut
}
if strings.Contains(value, "/") && store.Store != nil && store.Store.GetMediaHandler() != nil {
if fid := store.Store.GetMediaHandler().GetIdFromUrl(value); !fid.IsZero() {
return fid.String()
}
}
if fid := types.ParseUid(value); !fid.IsZero() {
return fid.String()
}
if dot := strings.IndexByte(value, '.'); dot > 0 {
if fid := types.ParseUid(value[:dot]); !fid.IsZero() {
return fid.String()
}
}
return ""
}
func linkIMGroupAvatarAttachment(tenantID types.TenantID, topic, avatarFileID string) error {
fileID := normalizeIMUploadedFileID(avatarFileID)
if fileID == "" {
return types.ErrMalformed
}
return store.Files.LinkAttachments(tenantID, topic, types.ZeroUid, []string{fileID})
}
func parseIMLimit(raw string, fallback, max int) int {
limit := fallback
if raw != "" {
@@ -7177,6 +7320,38 @@ func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any
return data
}
func groupBusinessMemberPayload(member types.GroupMember) map[string]any {
access := groupMemberAccess(member)
user := &types.User{ObjHeader: types.ObjHeader{Id: member.UserID.String()}, Public: member.Public, Trusted: member.Trusted}
data := userSummaryPayload(user, "")
if nickname := strings.TrimSpace(member.Nickname); nickname != "" {
data["group_nickname"] = nickname
}
data["role"] = groupMemberRole(access)
data["mentionable"] = access.IsJoiner() && access.IsReader()
data["acs"] = map[string]any{
"mode": access.String(),
"want": member.ModeWant.String(),
"given": member.ModeGiven.String(),
}
return data
}
func groupMemberAccess(member types.GroupMember) types.AccessMode {
access := member.ModeGiven & member.ModeWant
if access != types.ModeNone {
return access
}
switch member.Role {
case types.GroupMemberRoleOwner:
return types.ModeCFull
case types.GroupMemberRoleAdmin:
return types.ModeCPublic | types.ModeApprove
default:
return types.ModeCPublic
}
}
func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
data := groupMemberPayload(*sub, true)
data["topic"] = topic
@@ -7304,7 +7479,9 @@ func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSett
payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId()
payload["member_count"] = settings.MemberCount
if _, exists := payload["version"]; !exists {
payload["version"] = settings.Version
}
return payload
}
@@ -7626,16 +7803,17 @@ func groupMemberMatches(item map[string]any, query string) bool {
}
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit, cursor int, mentionableOnly, muteEligible bool) string {
limit int, cursor string, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
"v3",
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strconv.Itoa(cursor),
strings.TrimSpace(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
@@ -8331,7 +8509,18 @@ func meetingNoteListItemPayload(meeting *types.Meeting, files []types.MeetingNot
func meetingNotesDetailPayload(tenantID types.TenantID, meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid) map[string]any {
userSummaries := meetingUserSummaries(tenantID, []types.Meeting{*meeting})
data := meetingNoteListItemPayload(meeting, files, viewer, userSummaries)
meetingData := meetingPayloadWithUserSummaries(meeting, viewer, userSummaries)
data := make(map[string]any, len(meetingData)+4)
for k, v := range meetingData {
data[k] = v
}
data["meeting"] = meetingData
data["note_status"] = types.MeetingNoteStatusEmpty
if len(files) > 0 {
data["note_status"] = types.MeetingNoteStatusAvailable
}
data["file_count"] = len(files)
data["permissions"] = meetingNotePermissionsPayload(meeting, viewer)
fileItems := make([]map[string]any, 0, len(files))
for i := range files {
fileItems = append(fileItems, meetingNoteFilePayload(&files[i], viewer, userSummaries))
@@ -8622,6 +8811,7 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
"field_rules": map[string]any{
"nickname": map[string]any{"min_length": 1, "max_length": 32},
"signature": map[string]any{"max_length": 120},
"region": map[string]any{"max_length": 128},
"birth_date": map[string]any{"format": "YYYY-MM-DD"},
"gender": []string{"male", "female", "other", ""},
"email": map[string]any{"max_length": 128},
@@ -8630,6 +8820,15 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
return data
}
func validIMSecurityChallengeScene(scene string) bool {
switch strings.TrimSpace(scene) {
case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password":
return true
default:
return false
}
}
func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
return map[string]any{
"add_me_methods": settings.AddMeMethods,
@@ -8954,13 +9153,20 @@ func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]a
return data
}
func friendListItemPayload(item types.FriendListItem) map[string]any {
func friendListItemPayload(tenantID types.TenantID, viewer types.Uid, item types.FriendListItem) map[string]any {
online := false
onlineVisibility := "hidden"
if item.Friendship.Status == types.RelationshipFriend && imCanViewLastSeen(tenantID, viewer, item.Friendship.PeerID) {
onlineVisibility = "visible"
online = redisUserOnline(tenantID, item.Friendship.PeerID, false)
}
data := map[string]any{
"user_id": item.Friendship.PeerID.UserId(),
"relationship": item.Friendship.Status,
"remark": item.Friendship.Remark,
"topic": item.Friendship.Topic,
"online_visibility": "unknown",
"online": online,
"online_visibility": onlineVisibility,
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano),
"capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": imCanCall(item.Friendship.Status == types.RelationshipFriend)},
@@ -8977,6 +9183,10 @@ func friendListItemPayload(item types.FriendListItem) map[string]any {
data[key] = value
}
}
data["peer"] = userSummaryPayload(item.Peer, item.Friendship.Remark)
if peer, ok := data["peer"].(map[string]any); ok {
peer["online"] = online
}
}
if item.Friendship.Remark == "" && data["display_name"] == data["user_id"] {
data["display_name_source"] = "user_id"
@@ -9353,11 +9563,22 @@ func normalizeIMFavoriteSourceTopic(viewer types.Uid, topic string) (string, boo
return normalizeIMUserVisibleTopic(viewer, topic)
}
func favoriteItemIdempotencyKey(requestKey, topic string, seq int64) string {
requestKey = strings.TrimSpace(requestKey)
if requestKey == "" {
return ""
}
return stableIMEventID("favorite", requestKey, strings.TrimSpace(topic), seq)
}
func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
topic = strings.TrimSpace(topic)
if topic == "" {
return "", false
}
if topic == "slf" {
return viewer.SlfName(), !viewer.IsZero()
}
if len(topic) < 3 {
return "", false
}
@@ -9419,7 +9640,7 @@ func buildForwardBundle(principal *imPrincipal, sources []struct {
if len(failures) > 0 {
return nil, failures
}
userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages)
userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages, forwardBundleTitleUIDs(sourceTopics)...)
items := make([]map[string]any, 0, len(sourceMessages))
preview := make([]map[string]any, 0, min(len(sourceMessages), 3))
sourceRefs := make([]map[string]any, 0, len(sourceMessages))
@@ -9447,7 +9668,7 @@ func buildForwardBundle(principal *imPrincipal, sources []struct {
bundleID := "fbundle_" + store.Store.GetUidString()
return map[string]any{
"bundle_id": bundleID,
"title": "聊天记录",
"title": forwardBundleTitle(principal.TenantID, sourceTopics, userSummaries),
"item_count": len(items),
"preview": preview,
"items": items,
@@ -9457,12 +9678,80 @@ func buildForwardBundle(principal *imPrincipal, sources []struct {
}, nil
}
func forwardMessageUserSummaries(tenantID types.TenantID, messages []*types.Message) map[types.Uid]map[string]any {
if store.Users == nil || len(messages) == 0 {
func forwardBundleTitleUIDs(sourceTopics []string) []types.Uid {
seen := make(map[types.Uid]bool)
uids := make([]types.Uid, 0, 2)
for _, topic := range sourceTopics {
if types.GetTopicCat(topic) != types.TopicCatP2P {
continue
}
uid1, uid2, err := types.ParseP2P(topic)
if err != nil {
continue
}
for _, uid := range []types.Uid{uid1, uid2} {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
uids = append(uids, uid)
}
}
return uids
}
func forwardBundleTitle(tenantID types.TenantID, sourceTopics []string, userSummaries map[types.Uid]map[string]any) string {
if len(sourceTopics) == 0 {
return "聊天记录"
}
topic := sourceTopics[0]
switch types.GetTopicCat(topic) {
case types.TopicCatP2P:
uid1, uid2, err := types.ParseP2P(topic)
if err != nil {
return "聊天记录"
}
return forwardBundleDisplayName(uid1, userSummaries) + "和" + forwardBundleDisplayName(uid2, userSummaries) + "的聊天记录"
case types.TopicCatGrp:
groupName := topic
if store.Topics != nil {
if group, err := store.Topics.Get(tenantID, topic); err == nil && group != nil {
groupName = imDisplayNameFromPublic(group.Public, topic)
} else if err != nil {
logs.Warn.Printf("im message forward: failed to load group title topic=%s: %v", topic, err)
}
}
if strings.HasSuffix(groupName, "群") {
return groupName + "的聊天记录"
}
return groupName + "群的聊天记录"
default:
return "聊天记录"
}
}
func forwardBundleDisplayName(uid types.Uid, summaries map[types.Uid]map[string]any) string {
if summary := forwardMessageUserSummary(uid.String(), summaries); summary != nil {
if name := strings.TrimSpace(fmt.Sprint(summary["display_name"])); name != "" {
return name
}
}
return uid.UserId()
}
func forwardMessageUserSummaries(tenantID types.TenantID, messages []*types.Message, extraUIDs ...types.Uid) map[types.Uid]map[string]any {
if store.Users == nil {
return nil
}
seen := make(map[types.Uid]bool)
uids := make([]types.Uid, 0, len(messages))
uids := make([]types.Uid, 0, len(messages)+len(extraUIDs))
for _, uid := range extraUIDs {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
uids = append(uids, uid)
}
for _, msg := range messages {
if msg == nil {
continue
+359 -33
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
@@ -278,6 +279,7 @@ type testMeetingNotesStore struct {
createdMeetingID string
createdFileID string
createdIdempotencyKey string
createdFile *types.MeetingNoteFile
}
type testDailyReportsStore struct {
@@ -343,6 +345,9 @@ type testGroupBusinessStore struct {
mutes []types.GroupMute
files []types.GroupFile
invitations []types.GroupInvitation
listMembers []types.GroupMember
listMembersOpts types.GroupMemberListOptions
listMembersNext string
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode
@@ -400,8 +405,9 @@ func (s *testGroupBusinessStore) GetMember(_ types.TenantID, topic string, uid t
return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil
}
func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, _ types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
return nil, "", nil
func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
s.listMembersOpts = opts
return s.listMembers, s.listMembersNext, nil
}
func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
@@ -685,11 +691,18 @@ func (s *testMeetingNotesStore) CreateFile(_ types.TenantID, uid types.Uid, meet
s.createdMeetingID = meetingID
s.createdFileID = fileID
s.createdIdempotencyKey = idempotencyKey
return &types.MeetingNoteFile{
if s.createdFile != nil && s.createdFile.MeetingID == meetingID && s.createdFile.UploaderID == uid && s.createdFile.IdempotencyKey == idempotencyKey {
file := *s.createdFile
return &file, nil
}
file := &types.MeetingNoteFile{
NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid,
FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native",
IdempotencyKey: idempotencyKey, Version: 1, CreatedAt: now, UpdatedAt: now,
}, nil
}
s.createdFile = file
s.files = append(s.files, *file)
return file, nil
}
func (s *testMeetingNotesStore) ReplaceFile(_ types.TenantID, uid types.Uid, meetingID, noteFileID, fileID, idempotencyKey string, version int) (*types.MeetingNoteFile, error) {
@@ -2355,7 +2368,7 @@ func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) {
}
}
func TestServeIMSecurityChallengeCreateSkipsRateLimitWhenDisabled(t *testing.T) {
func TestServeIMSecurityChallengeCreateEnforcesResendLimit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
@@ -2396,11 +2409,26 @@ func TestServeIMSecurityChallengeCreateSkipsRateLimitWhenDisabled(t *testing.T)
serveIMSecurityChallenges(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String())
}
if verifications.created == nil || verifications.created.Action != "close_youth_mode" {
t.Fatalf("expected a new challenge while sensitive challenge rate limit is disabled: %#v", verifications.created)
if verifications.created != nil {
t.Fatalf("rate-limited request created a challenge: %#v", verifications.created)
}
}
func TestServeIMSecurityChallengeCreateRejectsUnknownScene(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges",
bytes.NewBufferString(`{"scene":"made_up_action","channel":"sms"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMSecurityChallenges(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
}
@@ -2996,7 +3024,9 @@ func TestServeIMMeetingNotesListAndDetail(t *testing.T) {
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
files := data["files"].([]any)
if data["recorder_user_id"] != viewer.UserId() || len(files) != 1 || files[0].(map[string]any)["file_id"] != "file_test" {
meetingData := data["meeting"].(map[string]any)
if data["recorder_user_id"] != viewer.UserId() || meetingData["recorder_user_id"] != viewer.UserId() || meetingData["start_at"] == "" ||
len(files) != 1 || files[0].(map[string]any)["file_id"] != "file_test" {
t.Fatalf("unexpected meeting notes detail: %#v", data)
}
}
@@ -3007,9 +3037,30 @@ func TestServeIMMeetingNoteFilesCreate(t *testing.T) {
viewer := types.Uid(42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, viewer)
mockStore.EXPECT().GetUidString().Return("note-1")
mockStore.EXPECT().GetUidString().Return("note-2")
now := time.Now().UTC().Round(time.Millisecond)
meeting := types.Meeting{
MeetingID: "mtg_test",
OrganizerID: viewer,
RecorderID: viewer,
Title: "纪要会",
StartAt: now.Add(-2 * time.Hour),
EndAt: now.Add(-time.Hour),
State: types.MeetingStateEnded,
Version: 1,
CreatedAt: now.Add(-3 * time.Hour),
UpdatedAt: now,
Participants: []types.MeetingParticipant{
{MeetingID: "mtg_test", UserID: viewer, Response: types.MeetingResponseAccepted},
},
}
oldMeetings := store.Meetings
oldMeetingNotes := store.MeetingNotes
store.MeetingNotes = &testMeetingNotesStore{}
notes := &testMeetingNotesStore{}
store.Meetings = &testMeetingsStore{meeting: &meeting}
store.MeetingNotes = notes
defer func() {
store.Meetings = oldMeetings
store.MeetingNotes = oldMeetingNotes
}()
@@ -3025,6 +3076,34 @@ func TestServeIMMeetingNoteFilesCreate(t *testing.T) {
if data["note_file_id"] != "mnote_note-1" || data["file_id"] != "file_test" || data["version"].(float64) != 1 {
t.Fatalf("unexpected note file payload: %#v", data)
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/meetings/mtg_test/notes/files", strings.NewReader(`{"file_id":"file_test"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "note-file-1")
rec = httptest.NewRecorder()
serveIMMeetingResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("duplicate status=%d body=%s", rec.Code, rec.Body.String())
}
duplicate := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if duplicate["note_file_id"] != "mnote_note-1" || len(notes.files) != 1 {
t.Fatalf("unexpected idempotent note file payload: data=%#v files=%#v", duplicate, notes.files)
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/meetings/mtg_test/notes", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMMeetingResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
}
detail := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
files := detail["files"].([]any)
meetingData := detail["meeting"].(map[string]any)
if len(files) != 1 || files[0].(map[string]any)["note_file_id"] != "mnote_note-1" ||
meetingData["meeting_id"] != "mtg_test" || meetingData["recorder_user_id"] != viewer.UserId() {
t.Fatalf("unexpected detail after create: %#v", detail)
}
}
func TestServeIMDailyReportsCreateAndList(t *testing.T) {
@@ -3802,14 +3881,14 @@ func TestServeIMGroupMembers(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldTopics := store.Topics
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
groupStore := &testGroupBusinessStore{listMembersNext: "101"}
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
store.GroupBusiness = oldGroupBusiness
}()
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{
@@ -3819,13 +3898,18 @@ func TestServeIMGroupMembers(t *testing.T) {
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
alice.SetPublic(map[string]any{"display_name": "Alice"})
bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin}
bob.SetPublic(map[string]any{"display_name": "Bob"})
topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil)
groupStore.listMembers = []types.GroupMember{{
ID: 101,
Topic: "grpMention",
UserID: types.Uid(101),
Role: types.GroupMemberRoleMember,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
Public: map[string]any{"display_name": "Alice", "avatar_file_id": "alice.jpg"},
MemberStatus: types.GroupMemberStatusActive,
}}
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true&limit=20&cursor=99", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -3836,12 +3920,21 @@ func TestServeIMGroupMembers(t *testing.T) {
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true {
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" ||
items[0].(map[string]any)["avatar_file_id"] != "alice.jpg" ||
items[0].(map[string]any)["mentionable"] != true {
t.Fatalf("unexpected members response: %#v", data)
}
if data["next_cursor"] != "101" {
t.Fatalf("unexpected cursor: %#v", data)
}
if data["permissions"].(map[string]any)["can_mention_all"] != true {
t.Fatalf("expected can_mention_all permission: %#v", data)
}
if groupStore.listMembersOpts.Query != "ali" || !groupStore.listMembersOpts.Mentionable ||
groupStore.listMembersOpts.Limit != 20 || groupStore.listMembersOpts.Cursor != "99" {
t.Fatalf("members options were not forwarded: %#v", groupStore.listMembersOpts)
}
}
func TestGroupMemberPayloadUserIDFormats(t *testing.T) {
@@ -4002,6 +4095,7 @@ func TestServeIMGroupCreate(t *testing.T) {
defer ctrl.Finish()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
oldFiles := store.Files
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
@@ -4014,23 +4108,27 @@ func TestServeIMGroupCreate(t *testing.T) {
oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.Files = files
groupBusiness := &testGroupBusinessStore{}
store.GroupBusiness = groupBusiness
defer func() {
store.Store = oldStore
store.Topics = oldTopics
store.Subs = oldSubs
store.Files = oldFiles
store.GroupBusiness = oldGroupBusiness
}()
avatarID := types.Uid(101).String()
topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpnewgroup"},
TenantID: 7,
Owner: types.Uid(42).String(),
SubCnt: 2,
Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "description": "Project discussion", "version": 1},
Public: map[string]any{"name": "New Group", "avatar_file_id": avatarID + ".jpg", "description": "Project discussion", "version": 1},
}, nil)
subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
@@ -4039,8 +4137,9 @@ func TestServeIMGroupCreate(t *testing.T) {
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
files.EXPECT().LinkAttachments(types.TenantID(7), "grpnewgroup", types.ZeroUid, []string{avatarID}).Return(nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`))
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"`+avatarID+`.jpg"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -4150,6 +4249,7 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
defer ctrl.Finish()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
oldFiles := store.Files
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
@@ -4161,13 +4261,16 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.Files = files
store.GroupBusiness = &testGroupBusinessStore{updateSettingsErr: types.ErrNotFound}
defer func() {
store.Store = oldStore
store.Topics = oldTopics
store.Subs = oldSubs
store.Files = oldFiles
store.GroupBusiness = oldGroupBusiness
}()
selfSub := &types.Subscription{
@@ -4185,9 +4288,11 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
SubCnt: 3,
Public: map[string]any{"name": "Alpha", "version": 1},
}
avatarID := types.Uid(100).String()
var savedPublic map[string]any
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil)
files.EXPECT().LinkAttachments(types.TenantID(7), "grpAlpha", types.ZeroUid, []string{avatarID}).Return(nil)
topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).DoAndReturn(
func(_ types.TenantID, _ string, update map[string]any) error {
public, ok := update["Public"].(map[string]any)
@@ -4210,7 +4315,7 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
},
)
req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"avatar_file_id":"file-avatar"}`))
req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"avatar_file_id":"`+avatarID+`.jpg"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
@@ -4218,7 +4323,7 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
if rec.Code != http.StatusOK {
t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String())
}
if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["avatar_file_id"] != "file-avatar" || data["version"].(float64) != 2 {
if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["avatar_file_id"] != avatarID+".jpg" || data["version"].(float64) != 2 {
t.Fatalf("unexpected patch response: %#v", data)
}
}
@@ -4442,8 +4547,9 @@ func TestServeIMGroupFiles(t *testing.T) {
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil).Times(3)
fileID := types.Uid(101).String()
createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files",
strings.NewReader(`{"file_id":"file1","file_name":"方案.pdf","file_type":"document","file_size":123}`))
strings.NewReader(fmt.Sprintf(`{"file_id":"%s.pdf","file_name":"方案.pdf","file_type":"document","file_size":123}`, fileID)))
createReq.Header.Set("Authorization", "Bearer token-1")
createReq.Header.Set("Idempotency-Key", "gfile-1")
createRec := httptest.NewRecorder()
@@ -4452,7 +4558,7 @@ func TestServeIMGroupFiles(t *testing.T) {
t.Fatalf("create status: got %d body=%s", createRec.Code, createRec.Body.String())
}
created := decodeIMHTTPResponse(t, createRec).Data.(map[string]any)
if created["file_id"] != "file1" || created["file_type"] != "document" {
if created["file_id"] != fileID || created["file_type"] != "document" {
t.Fatalf("unexpected create response: %#v", created)
}
@@ -4807,6 +4913,71 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
}
}
func TestServeIMFavoritesListSupportsRefOnlyAttachment(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7, FavoriteID: "fav_ref", UserID: 42, ItemType: "file",
SourceTopic: "grpAlpha", SourceSeq: 8, Availability: "available", CollectedAt: now,
}}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha",
ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(8)).Return(&types.Message{
TenantID: 7, Topic: "grpAlpha", SeqId: 8, From: types.Uid(42).String(),
Head: types.KVMap{"type": "file"}, Content: map[string]any{
"txt": " ",
"fmt": []any{map[string]any{"at": 0, "len": 1, "key": 0}},
"ent": []any{map[string]any{"tp": "EX", "data": map[string]any{
"mime": "application/pdf", "name": "large.pdf", "ref": "https://oss.example/large.pdf",
}}},
}, ObjHeader: types.ObjHeader{CreatedAt: now},
}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=all&limit=1", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavorites(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if items := data["items"].([]any); len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_ref" {
t.Fatalf("unexpected ref-only favorite response: %#v", data)
}
}
func TestNormalizeIMUserVisibleTopicSelf(t *testing.T) {
uid := types.Uid(42)
got, ok := normalizeIMUserVisibleTopic(uid, "slf")
if !ok || got != uid.SlfName() {
t.Fatalf("normalize slf: got=%q ok=%v want=%q", got, ok, uid.SlfName())
}
}
func TestMergeGroupSettingsPayloadPreservesTopicVersion(t *testing.T) {
payload := map[string]any{"version": 7}
mergeGroupSettingsPayload(payload, &types.GroupSettings{Version: 2})
if payload["version"] != 7 {
t.Fatalf("topic version was overwritten: %#v", payload)
}
}
func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -4870,6 +5041,74 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
}
}
func TestServeIMFavoriteBatchCreateAcceptsNumericSeq(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
mockStore.EXPECT().GetUidString().Return("new-fav")
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
favorites := &testFavoritesStore{}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Favorites = favorites
store.Subs = subs
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
})
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpAlpha",
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7,
Topic: "grpAlpha",
SeqId: 3,
From: types.Uid(42).String(),
Head: types.KVMap{"type": "text"},
Content: map[string]any{"text": "hello"},
ObjHeader: types.ObjHeader{
CreatedAt: now,
},
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch", bytes.NewBufferString(`{"messages":[{"topic":"grpAlpha","seq":3}]}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavoriteResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 {
t.Fatalf("unexpected favorite counts: %#v", data)
}
if favorites.created == nil || favorites.created.SourceSeq != 3 {
t.Fatalf("favorite source seq was not saved: %#v", favorites.created)
}
}
func TestFavoriteItemIdempotencyKeyIncludesMessageIdentity(t *testing.T) {
first := favoriteItemIdempotencyKey("same-key", "grpAlpha", 1)
second := favoriteItemIdempotencyKey("same-key", "grpAlpha", 2)
if first == "" || second == "" || first == second {
t.Fatalf("favorite item idempotency key must be stable per source message: %q %q", first, second)
}
if favoriteItemIdempotencyKey("", "grpAlpha", 1) != "" {
t.Fatal("empty request idempotency key should stay empty")
}
}
func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5369,16 +5608,20 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
oldSubs := store.Subs
oldMessages := store.Messages
oldUsers := store.Users
oldTopics := store.Topics
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
store.Users = users
store.Topics = topics
t.Cleanup(func() {
store.Subs = oldSubs
store.Messages = oldMessages
store.Users = oldUsers
store.Topics = oldTopics
})
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
@@ -5411,6 +5654,7 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
TenantID: 7,
Public: types.KVMap{"display_name": "后端-potter", "avatar_file_id": "avatar-99"},
}}, nil)
topics.EXPECT().Get(types.TenantID(7), "grpSource").Return(&types.Topic{Public: map[string]any{"fn": "研发群"}}, 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 != "grpTarget" || msg.Head["x-im-type"] != "forward_bundle" {
@@ -5420,6 +5664,9 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
if content["bundle_id"] != "fbundle_bundle-1" || content["item_count"] != 1 {
t.Fatalf("unexpected bundle content: %#v", content)
}
if content["title"] != "研发群的聊天记录" {
t.Fatalf("unexpected bundle title: %#v", content)
}
bundleItems := content["items"].([]map[string]any)
itemUser := bundleItems[0]["from_user"].(map[string]any)
if itemUser["user_id"] != types.Uid(99).UserId() || itemUser["display_name"] != "后端-potter" || itemUser["avatar_file_id"] != "avatar-99" {
@@ -5451,6 +5698,33 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
}
}
func TestForwardBundleTitleUsesP2PUsers(t *testing.T) {
uid1 := types.Uid(42)
uid2 := types.Uid(99)
title := forwardBundleTitle(types.TenantID(7), []string{uid1.P2PName(uid2)}, map[types.Uid]map[string]any{
uid1: {"display_name": "Alice"},
uid2: {"display_name": "Bob"},
})
if title != "Alice和Bob的聊天记录" {
t.Fatalf("unexpected p2p title: %s", title)
}
}
func TestForwardBundleTitleUsesGroupName(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldTopics := store.Topics
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Topics = topics
t.Cleanup(func() { store.Topics = oldTopics })
topics.EXPECT().Get(types.TenantID(7), "grpSource").Return(&types.Topic{Public: map[string]any{"fn": "项目群"}}, nil)
title := forwardBundleTitle(types.TenantID(7), []string{"grpSource"}, nil)
if title != "项目群的聊天记录" {
t.Fatalf("unexpected group title: %s", title)
}
}
func TestServeIMSecurityURLCheck(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -5694,10 +5968,10 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(3)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: actor, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
Return(&auth.Rec{TenantID: 7, Uid: actor, AuthLevel: auth.LevelAuth}, nil, nil).Times(3)
store.Store = mockStore
oldSubs := store.Subs
oldMessages := store.Messages
@@ -5717,10 +5991,11 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
Topic: normalizedTopic,
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil).Times(2)
}, nil).Times(3)
textMsg := types.Message{TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "好的"}}
imageMsg := types.Message{TenantID: 7, SeqId: 10, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}}
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "好的", "text", "", 50).Return([]types.Message{textMsg}, "", nil)
messages.EXPECT().GetAll(types.TenantID(7), normalizedTopic, actor, gomock.Any()).Return([]types.Message{textMsg}, nil)
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "", "image", "", 50).Return([]types.Message{imageMsg}, "", nil)
searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/search?q=%E5%A5%BD%E7%9A%84&type=text", nil)
@@ -5731,6 +6006,18 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String())
}
contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/9/context", nil)
contextReq.Header.Set("Authorization", "Bearer token-1")
contextRec := httptest.NewRecorder()
serveIMTopicMessageAction(contextRec, contextReq)
if contextRec.Code != http.StatusOK {
t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String())
}
contextData := decodeIMHTTPResponse(t, contextRec).Data.(map[string]any)
if contextData["topic"] != normalizedTopic {
t.Fatalf("context topic was not normalized: %#v", contextData)
}
mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/media?type=image", nil)
mediaReq.Header.Set("Authorization", "Bearer token-1")
mediaRec := httptest.NewRecorder()
@@ -6629,7 +6916,7 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{}
relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Relations = relations
store.Profiles = &testProfilesStore{}
defer func() {
@@ -6654,6 +6941,45 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
}
}
func TestServeIMFriendRequestCreateHonorsGroupPrivateChatSetting(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
oldProfiles := store.Profiles
oldSubs := store.Subs
oldGroups := store.GroupBusiness
store.Relations = &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Profiles = &testProfilesStore{}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
Topic: "grpAlpha", MemberPrivateChat: false, GroupStatus: types.GroupStatusActive,
}}
defer func() {
store.Relations = oldRelations
store.Profiles = oldProfiles
store.Subs = oldSubs
store.GroupBusiness = oldGroups
}()
memberMode := types.ModeCPublic
for _, uid := range []types.Uid{1, 2} {
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", uid, false).Return(&types.Subscription{
TenantID: 7, Topic: "grpAlpha", User: uid.String(), ModeWant: memberMode, ModeGiven: memberMode,
}, nil)
}
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","source":"group","source_topic":"grpAlpha"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "fr-group-1")
rec := httptest.NewRecorder()
serveIMFriendRequests(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
}
func TestServeIMFriendRequestList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+53 -4
View File
@@ -270,12 +270,25 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
func usersInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,
COALESCE(
NULLIF(avatar.location,''),
NULLIF(CASE WHEN p.avatar_file_id REGEXP '^(https?://|data:)' THEN p.avatar_file_id ELSE '' END,''),
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar')),''),
NULLIF(CASE WHEN JSON_TYPE(JSON_EXTRACT(u.public,'$.photo'))='STRING' THEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo')) ELSE '' END,''),
CASE WHEN JSON_TYPE(JSON_EXTRACT(u.public,'$.photo'))='OBJECT' AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.data')),'') IS NOT NULL
THEN CONCAT('data:image/',COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.type')),''),'jpeg'),';base64,',JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.data'))) ELSE NULL END,
NULLIF(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')) REGEXP '^(https?://|data:)' THEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')) ELSE '' END,''),
NULLIF((SELECT CASE WHEN om.avatar_file_id REGEXP '^(https?://|data:)' THEN om.avatar_file_id ELSE '' END FROM im_org_members AS om WHERE om.user_id=u.id AND om.avatar_file_id<>'' ORDER BY om.id LIMIT 1),''),
''
) AS avatar,
p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
CASE WHEN EXISTS(SELECT 1 FROM im_registration_invite_uses AS invite_use WHERE invite_use.user_id=u.id) THEN 'invite_code' WHEN COALESCE(u.phone,'')<>'' THEN 'mobile' ELSE 'admin' END AS register_type,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`,
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id",
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'",
DefaultSort: "ORDER BY u.id DESC",
DetailKey: "u.id",
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone)", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
}
}
@@ -330,7 +343,19 @@ func groupMessagesInternalQuerySpec() *internalQuerySpec {
LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"sender_user_id", "m.from_user", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.created_at", ">=", "time"}, {"created_at_end", "m.created_at", "<=", "time"}},
Filters: []internalQueryFilter{
{"topic", "m.topic", "LIKE", "string"},
{"seq_id", "m.seqid", "=", "int"},
{"sender_user_id", "m.from_user", "=", "uid"},
{"sender_username", "u.username", "LIKE", "string"},
{"sender_nickname", "p.nickname", "LIKE", "string"},
{"group_name", "gs.name", "LIKE", "string"},
{"content_type", contentType, "=", "string"},
{"status", status, "=", "string"},
{"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"},
{"created_at_start", "m.created_at", ">=", "time"},
{"created_at_end", "m.created_at", "<=", "time"},
},
}
}
@@ -392,6 +417,16 @@ func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, sco
if err != nil {
return "", "", nil, 0, 0, err
}
if filter.Kind == "uid_list" {
ids := value.([]any)
placeholders := make([]string, len(ids))
for i := range ids {
placeholders[i] = "?"
}
where = append(where, filter.Column+" IN ("+strings.Join(placeholders, ",")+")")
args = append(args, ids...)
continue
}
op := filter.Op
if op == "" {
op = "="
@@ -418,6 +453,20 @@ func internalAdminFilterValue(_ string, raw, kind string) (any, error) {
return id, nil
case "int":
return strconv.ParseInt(raw, 10, 64)
case "uid_list":
parts := strings.Split(raw, ",")
if len(parts) == 0 || len(parts) > 5000 {
return nil, types.ErrMalformed
}
ids := make([]any, 0, len(parts))
for _, part := range parts {
id, ok := parseInternalAdminDBID(strings.TrimSpace(part))
if !ok {
return nil, types.ErrMalformed
}
ids = append(ids, id)
}
return ids, nil
default:
return raw, nil
}
+83 -2
View File
@@ -18,6 +18,7 @@ import (
"sync"
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
@@ -79,7 +80,7 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}()
if req.Method != http.MethodGet {
if req.Method != http.MethodGet && req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
@@ -97,6 +98,10 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
if !ok {
return
}
if req.Method == http.MethodPost && !(len(parts) == 3 && parts[2] == "users") {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
switch {
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "overview":
@@ -107,7 +112,9 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
serveInternalAdminResourceList(wrt, req, tenant.ID, "private-messages", "")
case len(parts) == 3 && parts[2] == "group-messages":
serveInternalAdminResourceList(wrt, req, tenant.ID, "group-messages", "")
case len(parts) == 3 && parts[2] == "users":
case req.Method == http.MethodPost && len(parts) == 3 && parts[2] == "users":
serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users":
serveInternalUserQuery(wrt, req, tenant.ID, parts[3])
@@ -156,6 +163,80 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
}
}
type internalUserCreateRequest struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
Phone string `json:"phone"`
Password string `json:"password"`
}
func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
var body internalUserCreateRequest
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
body.Username = strings.ToLower(strings.TrimSpace(body.Username))
body.Nickname = strings.TrimSpace(body.Nickname)
body.Phone = strings.TrimSpace(body.Phone)
if body.Username == "" {
var err error
body.Username, err = randomRegistrationUsername()
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
if err := validateRegistrationUsername(body.Username); err != nil || len([]rune(body.Password)) < 3 || len([]byte(body.Password)) > 72 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "用户名或密码格式不正确", nil)
return
}
if body.Phone != "" {
phone, err := normalizeIMPhone(body.Phone, "CN")
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "手机号格式不正确", nil)
return
}
body.Phone = phone
}
authHandler := store.Store.GetLogicalAuthHandler("basic")
if authHandler == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "basic authentication unavailable", nil)
return
}
authCtx := auth.AuthContext{TenantID: tenantID, RemoteAddr: req.RemoteAddr}
secret := []byte(body.Username + ":" + body.Password)
if unique, err := authHandler.IsUnique(authCtx, secret); err != nil || !unique {
writeIMError(wrt, req, http.StatusConflict, 30001, "DUPLICATE_USER", "用户名已存在", nil)
return
}
user := &types.User{
TenantID: tenantID,
State: types.StateOK,
Username: body.Username,
Phone: body.Phone,
Public: map[string]any{"fn": firstNonEmpty(body.Nickname, body.Username)},
}
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) | getDefaultAccess(types.TopicCatGrp, true, false)
user.Access.Anon = getDefaultAccess(types.TopicCatP2P, false, false) | getDefaultAccess(types.TopicCatGrp, false, false)
created, err := store.Users.Create(tenantID, user, nil)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
if _, err = authHandler.AddRecord(authCtx, &auth.Rec{TenantID: tenantID, Uid: created.Uid()}, secret); err != nil {
_ = store.Users.Delete(tenantID, created.Uid(), true)
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"user_id": store.DecodeUid(created.Uid()),
"username": created.Username,
"nickname": body.Nickname,
"phone": created.Phone,
})
}
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
if !internalAPI.config.Enabled {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
+59
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -67,6 +68,9 @@ func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantI
SeqID: msg.SeqId,
UserID: types.ParseUid(msg.From).UserId(),
FileType: lifecycleFileTypeFromMessageHead(msg.Head),
FileName: lifecycleContentString(msg.Content, "name", "file_name"),
MIMEType: lifecycleContentString(msg.Content, "mime", "mime_type"),
FileSize: lifecycleContentInt64(msg.Content, "size", "file_size"),
OccurredAt: msg.CreatedAt,
}
return enqueueKafkaFileLifecycle(ctx, tenantID, kafkaFileLifecycleAttachmentsBind, fmt.Sprintf("%s:%d", msg.Topic, msg.SeqId), payload)
@@ -141,7 +145,10 @@ func executeKafkaFileAttachmentsBind(tenantID types.TenantID, payload kafkaFileL
SourceTopic: payload.Topic,
SourceSeq: payload.SeqID,
UploaderID: uploader,
FileName: payload.FileName,
MIMEType: payload.MIMEType,
FileType: lifecycleFileType(payload.FileType),
FileSize: payload.FileSize,
IdempotencyKey: fmt.Sprintf("%s:%d:%s", payload.Topic, payload.SeqID, fid.String()),
})
if err != nil && !errors.Is(err, types.ErrDuplicate) {
@@ -183,6 +190,58 @@ func lifecycleHeaderString(head types.KVMap, key string) string {
return strings.TrimSpace(str)
}
func lifecycleContentString(content any, keys ...string) string {
values, ok := lifecycleContentMap(content)
if !ok {
return ""
}
for _, key := range keys {
if value, ok := values[key]; ok {
if str, ok := value.(string); ok {
if str = strings.TrimSpace(str); str != "" {
return str
}
}
}
}
return ""
}
func lifecycleContentInt64(content any, keys ...string) int64 {
values, ok := lifecycleContentMap(content)
if !ok {
return 0
}
for _, key := range keys {
if value, ok := values[key]; ok {
switch typed := value.(type) {
case int:
return int64(typed)
case int64:
return typed
case float64:
return int64(typed)
case json.Number:
if parsed, err := typed.Int64(); err == nil {
return parsed
}
}
}
}
return 0
}
func lifecycleContentMap(content any) (map[string]any, bool) {
switch typed := content.(type) {
case map[string]any:
return typed, true
case types.KVMap:
return map[string]any(typed), true
default:
return nil, false
}
}
func lifecycleCanonicalMessageType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "audio":
+56 -6
View File
@@ -13,6 +13,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"net/http"
"os"
@@ -254,6 +255,52 @@ type mediaConfig struct {
Handlers map[string]json.RawMessage `json:"handlers"`
}
func resolveMediaHandlerConfig(config *mediaConfig) (string, string, error) {
if config == nil {
return "", "", nil
}
handler := strings.TrimSpace(config.UseHandler)
if handler == "sys_config" {
values, err := store.ThirdPartyConfig.Get("im_third_party", "media_handler")
if err != nil {
return "", "", err
}
handler = strings.TrimSpace(values["media_handler"])
}
if handler == "" {
return "", "", errors.New("empty media handler")
}
var conf string
if config.Handlers != nil {
if params := config.Handlers[handler]; params != nil {
conf = string(params)
}
}
if handler == "oss" {
ossConf, err := ensureOSSUsesSysConfig(conf)
if err != nil {
return "", "", err
}
return handler, ossConf, nil
}
return handler, conf, nil
}
func ensureOSSUsesSysConfig(conf string) (string, error) {
params := map[string]any{}
if strings.TrimSpace(conf) != "" {
if err := json.Unmarshal([]byte(conf), &params); err != nil {
return "", err
}
}
params["use_sys_config"] = true
encoded, err := json.Marshal(params)
if err != nil {
return "", err
}
return string(encoded), nil
}
// Contentx of the configuration file
type configType struct {
// HTTP(S) address:port to listen on for websocket and long polling clients. Either a
@@ -640,12 +687,13 @@ func main() {
} else {
globals.maxFileUploadSize = config.Media.MaxFileUploadSize
if config.Media.Handlers != nil {
var conf string
if params := config.Media.Handlers[config.Media.UseHandler]; params != nil {
conf = string(params)
handler, conf, err := resolveMediaHandlerConfig(config.Media)
if err != nil {
logs.Err.Fatalf("Failed to resolve media handler config: %s", err)
}
if err = store.Store.UseMediaHandler(config.Media.UseHandler, conf); err != nil {
logs.Err.Fatalf("Failed to init media handler '%s': %s", config.Media.UseHandler, err)
config.Media.UseHandler = handler
if err = store.Store.UseMediaHandler(handler, conf); err != nil {
logs.Err.Fatalf("Failed to init media handler '%s': %s", handler, err)
}
}
if config.Media.GcPeriod > 0 && config.Media.GcBlockSize > 0 {
@@ -799,6 +847,8 @@ func main() {
logs.Info.Printf("Server status is available at '%s'", sspath)
mux.HandleFunc(sspath, serveStatus)
}
mux.HandleFunc("/healthz", serveHealthz)
mux.HandleFunc("/metrics", serveMetrics)
// Handle websocket clients.
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
@@ -898,7 +948,7 @@ func main() {
mux.HandleFunc("/", serve404)
}
if err = listenAndServe(config.Listen, withIMHTTPProtocol(config.ApiPath+"v1/im/", mux), tlsConfig, signalHandler()); err != nil {
if err = listenAndServe(config.Listen, monitorHTTP(withIMHTTPProtocol(config.ApiPath+"v1/im/", mux)), tlsConfig, signalHandler()); err != nil {
logs.Err.Fatal(err)
}
}
+1 -2
View File
@@ -139,8 +139,7 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
return "", 0, err
}
fname := fdef.Id
fname += media.ExtensionByMimeType(fdef.MimeType)
fname := media.FileURLName(fdef)
fdef.Location = location
// Use file path to create ETag. File paths are unique so will be the ETag.
+52
View File
@@ -69,6 +69,58 @@ type AllowedOrigin struct {
var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`)
var uploadExtensionMimeTypes = map[string]string{
".aac": "audio/aac",
".m4a": "audio/x-m4a",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
// MimeTypeForUpload picks the MIME type to persist for an uploaded file. Some common formats are
// containers and are misdetected by http.DetectContentType, for example m4a as video/mp4 and Office
// documents as zip, so the original file extension wins for those cases.
func MimeTypeForUpload(detected, clientContentType, originalName string) string {
ext := ExtensionByFilename(originalName)
if extMime := uploadExtensionMimeTypes[ext]; extMime != "" {
return extMime
}
if clientContentType != "" {
return clientContentType
}
return strings.TrimSpace(detected)
}
// FileURLName returns the public URL file name for a stored file.
func FileURLName(fdef *types.FileDef) string {
if fdef == nil {
return ""
}
fname := fdef.Id
if ext := ExtensionByFilename(fdef.OriginalName); ext != "" {
return fname + ext
}
return fname + ExtensionByMimeType(fdef.MimeType)
}
// ExtensionByFilename returns a safe lower-case extension from a client-provided file name.
func ExtensionByFilename(name string) string {
_, fname := path.Split(strings.TrimSpace(name))
ext := strings.ToLower(path.Ext(fname))
if len(ext) < 2 || len(ext) > 16 {
return ""
}
for _, r := range ext[1:] {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
return ""
}
}
return ext
}
// ExtensionByMimeType returns a stable URL extension for uploaded media.
func ExtensionByMimeType(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
+70
View File
@@ -3,6 +3,8 @@ package media
import (
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestMatchCORSOrigin(t *testing.T) {
@@ -237,3 +239,71 @@ func TestExtensionByMimeType(t *testing.T) {
}
}
}
func TestMimeTypeForUploadPrefersContainerFilename(t *testing.T) {
cases := []struct {
name string
detect string
client string
filename string
want string
}{
{
name: "m4a misdetected as mp4",
detect: "video/mp4",
filename: "voice.m4a",
want: "audio/x-m4a",
},
{
name: "xlsx misdetected as zip",
detect: "application/zip",
filename: "iOS任务拆解.xlsx",
want: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
name: "client content type fallback",
detect: "application/octet-stream",
client: "image/jfif",
filename: "thumb",
want: "image/jfif",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := MimeTypeForUpload(tc.detect, tc.client, tc.filename); got != tc.want {
t.Fatalf("MimeTypeForUpload()=%q, want %q", got, tc.want)
}
})
}
}
func TestFileURLNamePrefersOriginalExtension(t *testing.T) {
cases := []struct {
name string
def *types.FileDef
want string
}{
{
name: "voice keeps m4a extension",
def: &types.FileDef{ObjHeader: types.ObjHeader{Id: "file123"}, MimeType: "video/mp4", OriginalName: "voice.m4a"},
want: "file123.m4a",
},
{
name: "xlsx keeps xlsx extension",
def: &types.FileDef{ObjHeader: types.ObjHeader{Id: "sheet123"}, MimeType: "application/zip", OriginalName: "iOS任务拆解.xlsx"},
want: "sheet123.xlsx",
},
{
name: "falls back to mime extension",
def: &types.FileDef{ObjHeader: types.ObjHeader{Id: "image123"}, MimeType: "image/jpeg"},
want: "image123.jpg",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := FileURLName(tc.def); got != tc.want {
t.Fatalf("FileURLName()=%q, want %q", got, tc.want)
}
})
}
}
+81 -9
View File
@@ -131,6 +131,10 @@ func (oh *ossHandler) loadSysConfig() error {
"oss_access_key_id",
"oss_access_key_secret",
"oss_presign_ttl",
"oss_serve_url",
"oss_cache_control",
"oss_cors_origins",
"oss_verify_bucket",
)
if err != nil {
return errors.New("failed to load OSS config from sys config: " + err.Error())
@@ -146,6 +150,14 @@ func (oh *ossHandler) loadSysConfig() error {
applyString(&oh.conf.RegionID, values["oss_region_id"])
applyString(&oh.conf.AccessKeyID, values["oss_access_key_id"])
applyString(&oh.conf.AccessKeySecret, values["oss_access_key_secret"])
applyString(&oh.conf.ServeURL, values["oss_serve_url"])
applyString(&oh.conf.CacheControl, values["oss_cache_control"])
if origins := parseStringList(values["oss_cors_origins"]); len(origins) > 0 {
oh.conf.CorsOrigins = origins
}
if verify := strings.TrimSpace(values["oss_verify_bucket"]); verify != "" {
oh.conf.VerifyBucket = truthy(verify)
}
if ttl := strings.TrimSpace(values["oss_presign_ttl"]); ttl != "" {
parsed, err := strconv.Atoi(ttl)
if err != nil {
@@ -156,6 +168,30 @@ func (oh *ossHandler) loadSysConfig() error {
return nil
}
func parseStringList(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
var items []string
if strings.HasPrefix(value, "[") {
if err := json.Unmarshal([]byte(value), &items); err == nil {
return compactStrings(items)
}
}
return compactStrings(strings.Split(value, ","))
}
func compactStrings(values []string) []string {
items := make([]string, 0, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
items = append(items, value)
}
}
return items
}
func applyString(target *string, value string) {
if value = strings.TrimSpace(value); value != "" {
*target = value
@@ -194,20 +230,23 @@ func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.U
"Cache-Control": {oh.conf.CacheControl},
}, http.StatusNotModified, nil
}
objectKey, err := oh.resolveObjectKey(fdef.Location)
if err != nil {
return nil, 0, err
}
var redirURL string
switch method {
case http.MethodGet:
options := []aliyunoss.Option{
aliyunoss.ResponseCacheControl(oh.conf.CacheControl),
aliyunoss.ResponseContentType(fdef.MimeType),
}
if isAttachment, _ := strconv.ParseBool(url.Query().Get("asatt")); isAttachment {
options = append(options, aliyunoss.ResponseContentDisposition("attachment"))
}
redirURL, err = oh.bucket.SignURL(fdef.Location, aliyunoss.HTTPGet, int64(oh.conf.PresignTTL), options...)
redirURL, err = oh.bucket.SignURL(objectKey, aliyunoss.HTTPGet, int64(oh.conf.PresignTTL), options...)
case http.MethodHead:
redirURL, err = oh.bucket.SignURL(fdef.Location, aliyunoss.HTTPHead, int64(oh.conf.PresignTTL))
redirURL, err = oh.bucket.SignURL(objectKey, aliyunoss.HTTPHead, int64(oh.conf.PresignTTL))
}
if err != nil {
return nil, 0, err
@@ -245,16 +284,17 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
return "", 0, err
}
fname := fdef.Id
fname += media.ExtensionByMimeType(fdef.MimeType)
fname := media.FileURLName(fdef)
fdef.Location = key
fdef.ETag = etagFromLocation(key)
if meta, err := oh.bucket.GetObjectDetailedMeta(key); err == nil {
meta, err := oh.bucket.GetObjectDetailedMeta(key)
if err != nil {
return "", 0, err
}
if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag
}
}
return oh.conf.ServeURL + fname, rc.count, nil
}
@@ -273,11 +313,13 @@ func (oh *ossHandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (s
}
fdef.Location = key
fdef.ETag = etagFromLocation(key)
if meta, err := oh.bucket.GetObjectDetailedMeta(key); err == nil {
meta, err := oh.bucket.GetObjectDetailedMeta(key)
if err != nil {
return "", 0, err
}
if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag
}
}
return key, rc.count, nil
}
@@ -316,6 +358,36 @@ func (oh *ossHandler) getFileRecord(tenantID types.TenantID, fid types.Uid) (*ty
return fd, nil
}
func (oh *ossHandler) resolveObjectKey(location string) (string, error) {
location = strings.TrimSpace(location)
if location == "" {
return "", types.ErrNotFound
}
if _, err := oh.bucket.GetObjectDetailedMeta(location); err == nil {
return location, nil
} else if !isOSSObjectNotFound(err) {
return "", err
}
if legacy := strings.TrimPrefix(location, "uploads/"); legacy != location {
if _, err := oh.bucket.GetObjectDetailedMeta(legacy); err == nil {
return legacy, nil
} else if !isOSSObjectNotFound(err) {
return "", err
}
}
return "", types.ErrNotFound
}
func isOSSObjectNotFound(err error) bool {
if err == nil {
return false
}
if serviceErr, ok := err.(aliyunoss.ServiceError); ok {
return serviceErr.StatusCode == http.StatusNotFound || serviceErr.Code == "NoSuchKey"
}
return false
}
func tenantObjectKey(tenantID types.TenantID, fileID string, createdAt time.Time) (string, error) {
if !tenantID.IsValid() || fileID == "" {
return "", types.ErrMalformed
+87
View File
@@ -0,0 +1,87 @@
package oss
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"testing"
"time"
aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/tinode/chat/server/media"
)
func TestLiveAliyunOSSUploadPublic(t *testing.T) {
endpoint := os.Getenv("LIVE_OSS_ENDPOINT")
bucket := os.Getenv("LIVE_OSS_BUCKET")
accessKeyID := os.Getenv("LIVE_OSS_ACCESS_KEY_ID")
accessKeySecret := os.Getenv("LIVE_OSS_ACCESS_KEY_SECRET")
if endpoint == "" || bucket == "" || accessKeyID == "" || accessKeySecret == "" {
t.Skip("LIVE_OSS_* env is not configured")
}
conf, err := json.Marshal(ossConfig{
Endpoint: endpoint,
BucketName: bucket,
AccessKeyID: accessKeyID,
AccessKeySecret: accessKeySecret,
PresignTTL: 60,
CacheControl: "no-cache",
CorsOrigins: []string{"*"},
})
if err != nil {
t.Fatal(err)
}
handler := &ossHandler{}
if err = handler.Init(string(conf)); err != nil {
t.Fatalf("oss init failed: %v", err)
}
body := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
file := &media.PublicFileDef{
ID: "codex-live-" + time.Now().UTC().Format("20060102150405"),
Purpose: "codex_live",
MimeType: "image/png",
CreatedAt: time.Now().UTC(),
}
location, size, err := handler.UploadPublic(file, bytes.NewReader(body))
if err != nil {
t.Fatalf("oss upload failed: %v", err)
}
defer func() {
if err := handler.Delete([]string{file.Location}); err != nil {
t.Logf("oss cleanup failed: %v", err)
}
}()
if size != int64(len(body)) || location == "" || file.Location == "" || file.ETag == "" {
t.Fatalf("unexpected upload result: location=%q file=%+v size=%d", location, file, size)
}
got, err := handler.bucket.GetObject(file.Location)
if err != nil {
t.Fatalf("oss get object failed: %v", err)
}
defer got.Close()
downloaded, err := io.ReadAll(got)
if err != nil {
t.Fatalf("oss read object failed: %v", err)
}
if !bytes.Equal(downloaded, body) {
t.Fatalf("oss downloaded bytes mismatch: got %x want %x", downloaded, body)
}
signedURL, err := handler.bucket.SignURL(file.Location, aliyunoss.HTTPGet, 60)
if err != nil {
t.Fatalf("oss sign url failed: %v", err)
}
resp, err := http.Get(signedURL)
if err != nil {
t.Fatalf("oss signed url get failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
t.Fatalf("oss signed url get status=%d", resp.StatusCode)
}
}
+22
View File
@@ -1,9 +1,12 @@
package oss
import (
"net/http"
"testing"
"time"
aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
@@ -46,6 +49,15 @@ func TestGetIdFromUrl(t *testing.T) {
}
}
func TestIsOSSObjectNotFound(t *testing.T) {
if !isOSSObjectNotFound(aliyunoss.ServiceError{StatusCode: http.StatusNotFound, Code: "NoSuchKey"}) {
t.Fatal("NoSuchKey should be treated as missing object")
}
if isOSSObjectNotFound(aliyunoss.ServiceError{StatusCode: http.StatusForbidden, Code: "AccessDenied"}) {
t.Fatal("AccessDenied must not be treated as missing object")
}
}
func TestLoadSysConfig(t *testing.T) {
original := store.ThirdPartyConfig
store.ThirdPartyConfig = fakeThirdPartyConfig{
@@ -57,6 +69,10 @@ func TestLoadSysConfig(t *testing.T) {
"oss_access_key_id": "ak",
"oss_access_key_secret": "sk",
"oss_presign_ttl": "300",
"oss_serve_url": "/files/",
"oss_cache_control": "private",
"oss_cors_origins": `["https://example.com"]`,
"oss_verify_bucket": "true",
}
defer func() { store.ThirdPartyConfig = original }()
@@ -70,4 +86,10 @@ func TestLoadSysConfig(t *testing.T) {
if handler.conf.RegionID != "cn-shenzhen" || handler.conf.PresignTTL != 300 {
t.Fatalf("unexpected OSS region or ttl: %+v", handler.conf)
}
if handler.conf.ServeURL != "/files/" || handler.conf.CacheControl != "private" || !handler.conf.VerifyBucket {
t.Fatalf("unexpected OSS serve settings: %+v", handler.conf)
}
if len(handler.conf.CorsOrigins) != 1 || handler.conf.CorsOrigins[0] != "https://example.com" {
t.Fatalf("unexpected OSS cors origins: %+v", handler.conf.CorsOrigins)
}
}
+1 -2
View File
@@ -294,8 +294,7 @@ func (ah *awshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
return "", 0, err
}
fname := fdef.Id
fname += media.ExtensionByMimeType(fdef.MimeType)
fname := media.FileURLName(fdef)
fdef.Location = key
if result.ETag != nil {
+4 -1
View File
@@ -23,7 +23,7 @@
"alias_tag": "alias",
"media": {
"use_handler": "fs",
"use_handler": "sys_config",
"max_size": 104857600,
"gc_period": 3600,
"gc_block_size": 100,
@@ -32,6 +32,9 @@
"upload_dir": "uploads",
"cache_control": "max-age=86400",
"cors_origins": ["*"]
},
"oss": {
"use_sys_config": true
}
}
},
+61
View File
@@ -0,0 +1,61 @@
package metrics
import (
"expvar"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
)
var (
Registry = prometheus.NewRegistry()
HTTPRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests by method, normalized path, and status.",
}, []string{"method", "path", "status"})
HTTPRequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration by method, normalized path, and status.",
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
}, []string{"method", "path", "status"})
DBQueryDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "db_query_duration_seconds",
Help: "Database operation duration by driver, operation, and status.",
Buckets: []float64{0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"driver", "operation", "status"})
WSConnectionsActive = prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "ws_connections_active",
Help: "Current active WebSocket connections.",
}, func() float64 {
return float64(expvarIntValue("LiveSessions"))
})
)
func init() {
Registry.MustRegister(WSConnectionsActive, HTTPRequestsTotal, HTTPRequestDuration, DBQueryDuration)
}
func ObserveDB(driverName, operation string, started time.Time, err error) {
status := "ok"
if err != nil {
status = "error"
}
DBQueryDuration.WithLabelValues(driverName, operation, status).Observe(time.Since(started).Seconds())
}
func expvarIntValue(name string) int64 {
value := expvar.Get(name)
if value == nil {
return 0
}
n, err := strconv.ParseInt(value.String(), 10, 64)
if err != nil {
return 0
}
return n
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"bufio"
"context"
"encoding/json"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store"
)
var (
metricsHandler = promhttp.HandlerFor(immetrics.Registry, promhttp.HandlerOpts{})
uuidLikeMetricSegment = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F-]{13,}$`)
)
type healthzResponse struct {
Status string `json:"status"`
Checks map[string]string `json:"checks"`
}
func serveHealthz(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
wrt.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
http.Error(wrt, "method not allowed", http.StatusMethodNotAllowed)
return
}
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
checks := map[string]string{}
status := http.StatusOK
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
defer cancel()
if err := store.Store.HealthCheck(ctx); err != nil {
checks["mysql"] = err.Error()
status = http.StatusServiceUnavailable
} else {
checks["mysql"] = "ok"
}
body := healthzResponse{Status: "ok", Checks: checks}
if status != http.StatusOK {
body.Status = "error"
}
wrt.WriteHeader(status)
if req.Method != http.MethodHead {
_ = json.NewEncoder(wrt).Encode(body)
}
}
func serveMetrics(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
wrt.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
http.Error(wrt, "method not allowed", http.StatusMethodNotAllowed)
return
}
metricsHandler.ServeHTTP(wrt, req)
}
func monitorHTTP(next http.Handler) http.Handler {
return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/metrics" {
next.ServeHTTP(wrt, req)
return
}
started := time.Now()
rec := &monitoringResponseWriter{ResponseWriter: wrt, status: http.StatusOK}
next.ServeHTTP(rec, req)
status := strconv.Itoa(rec.status)
path := normalizeMetricPath(req.URL.Path)
immetrics.HTTPRequestsTotal.WithLabelValues(req.Method, path, status).Inc()
immetrics.HTTPRequestDuration.WithLabelValues(req.Method, path, status).Observe(time.Since(started).Seconds())
})
}
type monitoringResponseWriter struct {
http.ResponseWriter
status int
}
func (w *monitoringResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *monitoringResponseWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *monitoringResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return h.Hijack()
}
func (w *monitoringResponseWriter) Push(target string, opts *http.PushOptions) error {
p, ok := w.ResponseWriter.(http.Pusher)
if !ok {
return http.ErrNotSupported
}
return p.Push(target, opts)
}
func (w *monitoringResponseWriter) ReadFrom(r io.Reader) (int64, error) {
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
return rf.ReadFrom(r)
}
return io.Copy(w.ResponseWriter, r)
}
func normalizeMetricPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/"
}
parts := strings.Split(path, "/")
for i, part := range parts {
if part == "" {
continue
}
if isMetricDynamicSegment(part) {
parts[i] = "{id}"
}
}
return strings.Join(parts, "/")
}
func isMetricDynamicSegment(segment string) bool {
if strings.HasPrefix(segment, "usr") || strings.HasPrefix(segment, "grp") || strings.HasPrefix(segment, "p2p") || strings.HasPrefix(segment, "chn") {
return true
}
if _, err := strconv.ParseInt(segment, 10, 64); err == nil {
return true
}
if uuidLikeMetricSegment.MatchString(segment) {
return true
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
immetrics "github.com/tinode/chat/server/metrics"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
)
func TestServeHealthzOK(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
oldStore := store.Store
store.Store = mockStore
t.Cleanup(func() { store.Store = oldStore })
mockStore.EXPECT().HealthCheck(gomock.Any()).Return(nil)
wrt := httptest.NewRecorder()
serveHealthz(wrt, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if wrt.Code != http.StatusOK {
t.Fatalf("unexpected status %d body=%s", wrt.Code, wrt.Body.String())
}
if body := wrt.Body.String(); !strings.Contains(body, `"status":"ok"`) || !strings.Contains(body, `"mysql":"ok"`) {
t.Fatalf("unexpected body %s", body)
}
}
func TestServeHealthzError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
oldStore := store.Store
store.Store = mockStore
t.Cleanup(func() { store.Store = oldStore })
mockStore.EXPECT().HealthCheck(gomock.Any()).Return(errors.New("dial timeout"))
wrt := httptest.NewRecorder()
serveHealthz(wrt, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if wrt.Code != http.StatusServiceUnavailable {
t.Fatalf("unexpected status %d body=%s", wrt.Code, wrt.Body.String())
}
if body := wrt.Body.String(); !strings.Contains(body, `"status":"error"`) || !strings.Contains(body, `"mysql":"dial timeout"`) {
t.Fatalf("unexpected body %s", body)
}
}
func TestServeMetricsIncludesWSConnections(t *testing.T) {
wrt := httptest.NewRecorder()
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if wrt.Code != http.StatusOK {
t.Fatalf("unexpected status %d body=%s", wrt.Code, wrt.Body.String())
}
body := wrt.Body.String()
if !strings.Contains(body, "# TYPE ws_connections_active gauge") || !strings.Contains(body, "ws_connections_active ") {
t.Fatalf("unexpected body %s", body)
}
}
func TestMonitorHTTPRecordsRequestMetrics(t *testing.T) {
handler := monitorHTTP(http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
wrt.WriteHeader(http.StatusCreated)
}))
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/im/users/1234567890123456789/profile", nil))
wrt := httptest.NewRecorder()
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := wrt.Body.String()
if !strings.Contains(body, `http_requests_total{method="POST",path="/v1/im/users/{id}/profile",status="201"} 1`) {
t.Fatalf("request counter was not exported: %s", body)
}
if !strings.Contains(body, `http_request_duration_seconds_bucket{method="POST",path="/v1/im/users/{id}/profile",status="201"`) {
t.Fatalf("request duration was not exported: %s", body)
}
}
func TestServeMetricsIncludesDBQueryDuration(t *testing.T) {
immetrics.ObserveDB("mysql", "query", time.Now().Add(-time.Millisecond), nil)
wrt := httptest.NewRecorder()
serveMetrics(wrt, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := wrt.Body.String()
if !strings.Contains(body, `db_query_duration_seconds_bucket{driver="mysql",operation="query",status="ok"`) {
t.Fatalf("db query duration was not exported: %s", body)
}
}
+61
View File
@@ -1147,6 +1147,12 @@ func (s *Session) login(msg *ClientComMessage) {
if err == nil && rec.State != types.StateOK {
err = types.ErrPermissionDenied
}
if err == nil {
err = checkTenantLoginIPWhitelist(s.tenantID, s.platf, s.remoteAddr)
}
if err == nil {
err = checkUserLoginIPWhitelist(s.tenantID, rec.Uid, s.remoteAddr)
}
if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
@@ -1286,6 +1292,10 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Record deviceId used in this session
if s.deviceID != "" {
isNewDevice, deviceErr := isNewLoginDevice(s.tenantID, rec.Uid, s.deviceID)
if deviceErr != nil {
logs.Warn.Println("s.onLogin: failed to check known device", rec.Uid, deviceErr, s.sid)
}
if err := store.Devices.Update(s.tenantID, rec.Uid, "", &types.DeviceDef{
TenantID: s.tenantID,
DeviceId: s.deviceID,
@@ -1294,6 +1304,8 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
Lang: s.lang,
}); err != nil {
logs.Warn.Println("failed to update device record", err)
} else if isNewDevice {
createLoginDeviceSecurityNotice(s.tenantID, rec.Uid, s.deviceID, s.platf, s.userAgent, timestamp)
}
}
if !s.uid.IsZero() {
@@ -1310,6 +1322,55 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
return reply
}
func isNewLoginDevice(tenantID types.TenantID, uid types.Uid, deviceID string) (bool, error) {
if store.Devices == nil || strings.TrimSpace(deviceID) == "" || uid.IsZero() {
return false, nil
}
devices, _, err := store.Devices.GetAll(tenantID, uid)
if err != nil {
return false, err
}
for _, device := range devices[uid] {
if device.DeviceId == deviceID {
return false, nil
}
}
return true, nil
}
func createLoginDeviceSecurityNotice(tenantID types.TenantID, uid types.Uid, deviceID, platform, userAgent string, at time.Time) {
if store.Security == nil || uid.IsZero() || strings.TrimSpace(deviceID) == "" {
return
}
eventID := stableIMEventID("security.login_device", uid.UserId(), deviceID)
if err := createIMSystemNotification(tenantID, uid, eventID, "security.login_device",
"新设备登录提醒", loginDeviceSecurityNoticeSummary(platform, userAgent),
map[string]any{
"scene": "login_device",
"device_id": deviceID,
"platform": strings.TrimSpace(platform),
"user_agent": strings.TrimSpace(userAgent),
"login_at": at.UTC().Format(time.RFC3339Nano),
}); err != nil {
logs.Warn.Println("s.onLogin: failed to create login device notification", uid, err)
}
}
func loginDeviceSecurityNoticeSummary(platform, userAgent string) string {
platform = strings.TrimSpace(platform)
if platform == "" {
platform = "未知设备"
}
userAgent = strings.TrimSpace(userAgent)
if userAgent == "" {
return platform + " 登录了你的账号"
}
if len([]rune(userAgent)) > 80 {
userAgent = string([]rune(userAgent)[:80])
}
return platform + " 登录了你的账号:" + userAgent
}
func currentNodeID() string {
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
return globals.cluster.thisNodeName
+76
View File
@@ -19,6 +19,48 @@ type testTenantStore struct {
err error
}
type testLoginDeviceStore struct {
devices map[types.Uid][]types.DeviceDef
updates int
err error
}
func (s *testLoginDeviceStore) Update(tenantID types.TenantID, uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error {
s.updates++
if s.err != nil {
return s.err
}
if dev != nil {
dev.TenantID = tenantID
s.devices[uid] = append(s.devices[uid], *dev)
}
return nil
}
func (s *testLoginDeviceStore) GetAll(_ types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
if s.err != nil {
return nil, 0, s.err
}
result := make(map[types.Uid][]types.DeviceDef)
count := 0
for _, uid := range uids {
result[uid] = append([]types.DeviceDef(nil), s.devices[uid]...)
count += len(result[uid])
}
return result, count, nil
}
func (s *testLoginDeviceStore) Delete(_ types.TenantID, uid types.Uid, deviceID string) error {
devices := s.devices[uid]
for i, device := range devices {
if device.DeviceId == deviceID {
s.devices[uid] = append(devices[:i], devices[i+1:]...)
return nil
}
}
return types.ErrNotFound
}
func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
return s.tenant, s.err
}
@@ -46,6 +88,40 @@ func test_makeSession(uid types.Uid) *Session {
}
}
func TestLoginDeviceSecurityNoticeOnlyForNewDevice(t *testing.T) {
uid := types.Uid(42)
devices := &testLoginDeviceStore{devices: map[types.Uid][]types.DeviceDef{
uid: {{TenantID: 7, DeviceId: "known-device", Platform: "ios"}},
}}
security := &testSecurityStore{}
oldDevices := store.Devices
oldSecurity := store.Security
store.Devices = devices
store.Security = security
t.Cleanup(func() {
store.Devices = oldDevices
store.Security = oldSecurity
})
known, err := isNewLoginDevice(7, uid, "known-device")
if err != nil || known {
t.Fatalf("known device detected as new: new=%v err=%v", known, err)
}
newDevice, err := isNewLoginDevice(7, uid, "new-device")
if err != nil || !newDevice {
t.Fatalf("new device was not detected: new=%v err=%v", newDevice, err)
}
createLoginDeviceSecurityNotice(7, uid, "new-device", "ios", "UnitTestUA", time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC))
if len(security.notifications) != 1 {
t.Fatalf("expected one security notification, got %d", len(security.notifications))
}
notification := security.notifications[0]
if notification.EventType != "security.login_device" || notification.Title == "" || notification.Action["device_id"] != "new-device" {
t.Fatalf("unexpected login device notification: %#v", notification)
}
}
func TestDispatchHello(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
+9
View File
@@ -77,18 +77,27 @@ func statsInit(mux *http.ServeMux, path string) {
func statsRegisterDbStats() {
if f := store.Store.DbStats(); f != nil {
if expvar.Get("DbStats") != nil {
return
}
expvar.Publish("DbStats", expvar.Func(f))
}
}
// Register integer variable. Don't check for initialization.
func statsRegisterInt(name string) {
if expvar.Get(name) != nil {
return
}
expvar.Publish(name, new(expvar.Int))
}
// Register histogram variable. `bounds` specifies histogram buckets/bins
// (see comment next to the `histogram` struct definition).
func statsRegisterHistogram(name string, bounds []float64) {
if expvar.Get(name) != nil {
return
}
numBuckets := len(bounds) + 1
expvar.Publish(name, &histogram{
CountPerBucket: make([]int64, numBuckets),
+29
View File
@@ -71,6 +71,7 @@ type groupMessageRuntime interface {
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, idPrefix string) error
AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
@@ -226,6 +227,13 @@ func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
}
func DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, idPrefix string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || idPrefix == "" {
return nil
}
return groupMsgRuntime.DeleteCachedObjectsByPrefix(tenantID, namespace, idPrefix)
}
func CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return nil
@@ -788,6 +796,27 @@ func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, n
return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
}
func (r *redisGroupMessageRuntime) DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, idPrefix string) error {
ctx := context.Background()
pattern := r.objectCacheKey(tenantID, namespace, idPrefix+"*")
var cursor uint64
for {
keys, next, err := r.client.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return err
}
if len(keys) > 0 {
if err = r.client.Del(ctx, keys...).Err(); err != nil {
return err
}
}
cursor = next
if cursor == 0 {
return nil
}
}
}
func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
@@ -11,6 +11,7 @@ import (
type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord
seqs map[string]int64
deletedPrefixes []string
}
func newTestGroupMessageRuntime() *testGroupMessageRuntime {
@@ -116,6 +117,11 @@ func (r *testGroupMessageRuntime) DeleteCachedObject(types.TenantID, string, str
return nil
}
func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(_ types.TenantID, _ string, prefix string) error {
r.deletedPrefixes = append(r.deletedPrefixes, prefix)
return nil
}
func (r *testGroupMessageRuntime) AddCachedSetMember(types.TenantID, string, string, string) error {
return nil
}
@@ -176,3 +182,15 @@ func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
t.Fatalf("unexpected second seq: seq=%d ready=%v err=%v", seq, ready, err)
}
}
func TestInvalidateGroupMemberPagesUsesTopicKeyPrefix(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()
groupMsgRuntime = runtime
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
invalidateGroupMemberPages(7, "grpAlpha")
if len(runtime.deletedPrefixes) != 1 || runtime.deletedPrefixes[0] != "grpAlpha|" {
t.Fatalf("unexpected deleted prefixes: %#v", runtime.deletedPrefixes)
}
}
+15
View File
@@ -5,6 +5,7 @@
package mock_store
import (
context "context"
json "encoding/json"
reflect "reflect"
time "time"
@@ -138,6 +139,20 @@ func (mr *MockPersistentStorageInterfaceMockRecorder) GetAuthNames() *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthNames", reflect.TypeOf((*MockPersistentStorageInterface)(nil).GetAuthNames))
}
// HealthCheck mocks base method.
func (m *MockPersistentStorageInterface) HealthCheck(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HealthCheck", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// HealthCheck indicates an expected call of HealthCheck.
func (mr *MockPersistentStorageInterfaceMockRecorder) HealthCheck(ctx interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HealthCheck", reflect.TypeOf((*MockPersistentStorageInterface)(nil).HealthCheck), ctx)
}
// GetDbVersion mocks base method.
func (m *MockPersistentStorageInterface) GetDbVersion() int {
m.ctrl.T.Helper()
+190 -25
View File
@@ -2,6 +2,7 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -120,6 +121,7 @@ type PersistentStorageInterface interface {
GetUid() types.Uid
GetUidString() string
DbStats() func() any
HealthCheck(context.Context) error
GetAuthNames() []string
GetAuthHandler(name string) auth.AuthHandler
GetLogicalAuthHandler(name string) auth.AuthHandler
@@ -294,6 +296,16 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
if tenant, ok := tenantByCodeCache.get(code); ok {
return cloneTenant(tenant), nil
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if ok {
if tenant, err := tenantAdp.TenantGetByCode(code); err == nil && tenant != nil {
tenantByCodeCache.set(code, cloneTenant(tenant))
tenantByIDCache.set(strconv.FormatInt(int64(tenant.ID), 10), cloneTenant(tenant))
return tenant, nil
} else if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("local tenant by code lookup failed: code=%s err=%v", code, err)
}
}
if adminInternalConfigProvider != nil {
if tenant, err := adminInternalConfigProvider.GetTenantByCode(code); err == nil && tenant != nil {
tenantByCodeCache.set(code, cloneTenant(tenant))
@@ -303,16 +315,10 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
logs.Warn.Printf("admin internal tenant by code fallback failed: code=%s err=%v", code, err)
}
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
tenant, err := tenantAdp.TenantGetByCode(code)
if err == nil && tenant != nil {
tenantByCodeCache.set(code, cloneTenant(tenant))
tenantByIDCache.set(strconv.FormatInt(int64(tenant.ID), 10), cloneTenant(tenant))
}
return tenant, err
return tenantAdp.TenantGetByCode(code)
}
// GetByID resolves an internal tenant ID to its registry record.
@@ -324,6 +330,16 @@ func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
if tenant, ok := tenantByIDCache.get(idKey); ok {
return cloneTenant(tenant), nil
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if ok {
if tenant, err := tenantAdp.TenantGetByID(id); err == nil && tenant != nil {
tenantByIDCache.set(idKey, cloneTenant(tenant))
tenantByCodeCache.set(tenant.Code, cloneTenant(tenant))
return tenant, nil
} else if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("local tenant by id lookup failed: tenant=%d err=%v", id, err)
}
}
if adminInternalConfigProvider != nil {
if tenant, err := adminInternalConfigProvider.GetTenantByID(id); err == nil && tenant != nil {
tenantByIDCache.set(idKey, cloneTenant(tenant))
@@ -333,22 +349,29 @@ func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
logs.Warn.Printf("admin internal tenant by id fallback failed: tenant=%d err=%v", id, err)
}
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
tenant, err := tenantAdp.TenantGetByID(id)
if err == nil && tenant != nil {
tenantByIDCache.set(idKey, cloneTenant(tenant))
tenantByCodeCache.set(tenant.Code, cloneTenant(tenant))
}
return tenant, err
return tenantAdp.TenantGetByID(id)
}
func (tenantsMapper) ListActive(limit int) ([]types.Tenant, error) {
if limit <= 0 {
limit = 1000
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if ok {
if tenants, err := tenantAdp.TenantListActive(limit); err == nil {
for i := range tenants {
tenant := tenants[i]
tenantByIDCache.set(strconv.FormatInt(int64(tenant.ID), 10), cloneTenant(&tenant))
tenantByCodeCache.set(tenant.Code, cloneTenant(&tenant))
}
return tenants, nil
} else {
logs.Warn.Printf("local active tenants lookup failed: err=%v", err)
}
}
if adminInternalConfigProvider != nil {
if tenants, err := adminInternalConfigProvider.ListActiveTenants(limit); err == nil {
for i := range tenants {
@@ -361,7 +384,6 @@ func (tenantsMapper) ListActive(limit int) ([]types.Tenant, error) {
logs.Warn.Printf("admin internal active tenants fallback failed: err=%v", err)
}
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
@@ -473,6 +495,19 @@ func (storeObj) GetAdapter() adapter.Adapter {
return adp
}
// HealthCheck verifies that the persistent storage adapter can execute a real operation.
func (storeObj) HealthCheck(ctx context.Context) error {
if adp == nil || !adp.IsOpen() {
return errors.New("store is not open")
}
if checker, ok := adp.(interface {
HealthCheck(context.Context) error
}); ok {
return checker.HealthCheck(ctx)
}
return errors.New("health check is not supported by " + adp.GetName())
}
// GetAdapterName returns the name of the current adater.
func (storeObj) GetAdapterName() string {
if adp != nil {
@@ -1760,6 +1795,16 @@ func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (map[string]a
return nil, false
}
func invalidateGroupMemberPages(tenantID types.TenantID, topic string) {
topic = strings.TrimSpace(topic)
if topic == "" {
return
}
if err := DeleteCachedObjectsByPrefix(tenantID, cacheNamespaceGroupPage, topic+"|"); err != nil {
logs.Warn.Printf("cache group member pages[%s]: delete failed: %v", topic, err)
}
}
func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() {
return nil, types.ErrMalformed
@@ -1775,6 +1820,7 @@ func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, u
} else {
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid))
}
invalidateGroupMemberPages(tenantID, topic)
}
return member, err
}
@@ -1798,7 +1844,11 @@ func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationI
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
invitation, err := tenantAdp.TenantGroupInvitationAccept(tenantID, strings.TrimSpace(invitationID), uid, memberPrivate, now)
if err == nil && invitation != nil {
invalidateGroupMemberPages(tenantID, invitation.Topic)
}
return invitation, err
}
func (groupBusinessMapper) CreateJoinRequest(tenantID types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
@@ -1834,7 +1884,11 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
resolved, err := tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
if err == nil && resolved != nil && resolved.Status == types.GroupJoinRequestStatusAccepted {
invalidateGroupMemberPages(tenantID, topic)
}
return resolved, err
}
func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) {
@@ -1951,8 +2005,12 @@ func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, i
if err != nil {
return false, err
}
return tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
created, err := tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
if err == nil {
invalidateGroupMemberPages(tenantID, topic)
}
return created, err
}
func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error {
@@ -1969,6 +2027,7 @@ func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid type
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true))
invalidateGroupMemberPages(tenantID, topic)
return nil
}
@@ -1987,6 +2046,7 @@ func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, act
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), target))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, true))
invalidateGroupMemberPages(tenantID, topic)
return nil
}
@@ -1999,7 +2059,17 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
if err != nil {
return err
}
return tenantAdp.TenantGroupOwnerTransfer(tenantID, strings.TrimSpace(topic), previousOwner, newOwner, version, now)
topic = strings.TrimSpace(topic)
if err = tenantAdp.TenantGroupOwnerTransfer(tenantID, topic, previousOwner, newOwner, version, now); err != nil {
return err
}
for _, uid := range []types.Uid{previousOwner, newOwner} {
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(topic, uid))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, uid, true))
}
invalidateGroupMemberPages(tenantID, topic)
return nil
}
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
@@ -2834,7 +2904,85 @@ func (profileMapper) Update(tenantID types.TenantID, uid types.Uid, patch types.
if err != nil {
return nil, err
}
return tenantAdp.TenantUserProfileUpdate(tenantID, uid, patch)
profile, err := tenantAdp.TenantUserProfileUpdate(tenantID, uid, patch)
if err != nil {
return nil, err
}
refreshUserProfileProjections(tenantID, uid, profile)
return profile, nil
}
func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profile *types.UserProfile) {
_ = DeleteCachedObject(tenantID, cacheNamespaceUser, uid.String())
if profile == nil {
return
}
public := userProfilePublicProjection(profile)
if user, err := Users.Get(tenantID, uid); err != nil {
logs.Warn.Printf("profile %s: load native user projection failed: %v", uid, err)
} else if user != nil {
merged := make(map[string]any)
if encoded, marshalErr := json.Marshal(user.Public); marshalErr == nil {
_ = json.Unmarshal(encoded, &merged)
}
for key, value := range public {
merged[key] = value
}
if err = Users.Update(tenantID, uid, map[string]any{"Public": merged}); err != nil {
logs.Warn.Printf("profile %s: update native user projection failed: %v", uid, err)
} else {
public = merged
}
}
cursor := ""
for {
items, nextCursor, err := Relations.ListFriends(tenantID, uid, "", "", cursor, 100)
if err != nil {
logs.Warn.Printf("profile %s: refresh friend subscription projections failed: %v", uid, err)
break
}
for _, item := range items {
if item.Friendship.Topic == "" || item.Friendship.PeerID.IsZero() {
continue
}
if err := Subs.Update(tenantID, item.Friendship.Topic, item.Friendship.PeerID, map[string]any{"Public": public}); err != nil {
logs.Warn.Printf("profile %s: refresh subscription %s/%s public failed: %v", uid, item.Friendship.Topic, item.Friendship.PeerID, err)
}
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if topics, err := Users.GetTopics(tenantID, uid, &types.QueryOpt{Limit: 1000}); err != nil {
logs.Warn.Printf("profile %s: refresh group member projections failed: %v", uid, err)
} else {
for _, sub := range topics {
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
continue
}
if err := Subs.Update(tenantID, sub.Topic, uid, map[string]any{"Public": public}); err != nil {
logs.Warn.Printf("profile %s: refresh group subscription %s public failed: %v", uid, sub.Topic, err)
}
invalidateGroupMemberPages(tenantID, sub.Topic)
}
}
}
func userProfilePublicProjection(profile *types.UserProfile) map[string]any {
nickname := strings.TrimSpace(profile.Nickname)
public := map[string]any{
"display_name": nickname,
"nickname": nickname,
"fn": nickname,
"name": nickname,
"avatar_file_id": strings.TrimSpace(profile.AvatarFileID),
"signature": strings.TrimSpace(profile.Signature),
"gender": strings.TrimSpace(profile.Gender),
"region": strings.TrimSpace(profile.Region),
"public_account": strings.TrimSpace(profile.PublicAccount),
}
return public
}
func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) {
@@ -2900,6 +3048,9 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 {
return false
}
if patch.Region != nil && len([]rune(strings.TrimSpace(*patch.Region))) > 128 {
return false
}
return true
}
@@ -4155,19 +4306,33 @@ func (fileMapper) DeleteUnused(tenantID types.TenantID, olderThan time.Time, lim
// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
// from being garbage collected.
func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
// Convert attachment URLs to file IDs.
var fids []string
for _, url := range attachments {
if fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() {
raw := strings.TrimSpace(url)
if raw == "" {
continue
}
if mediaHandler != nil {
if fid := mediaHandler.GetIdFromUrl(raw); !fid.IsZero() {
fids = append(fids, fid.String())
continue
}
}
if fid := media.GetIdFromUrl(raw, ""); !fid.IsZero() {
fids = append(fids, fid.String())
continue
}
if fid := types.ParseUid(raw); !fid.IsZero() {
fids = append(fids, fid.String())
}
}
if len(fids) > 0 {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
userId := types.ZeroUid
if topic != "" && types.GetTopicCat(topic) == types.TopicCatMe {
userId = types.ParseUserId(topic)
+5
View File
@@ -73,6 +73,10 @@ type GroupMember struct {
Role string `db:"role"`
MemberStatus string `db:"member_status"`
Nickname string `db:"nickname"`
ModeWant AccessMode `db:"modewant"`
ModeGiven AccessMode `db:"modegiven"`
Public any `db:"public"`
Trusted any `db:"trusted"`
Pinned bool `db:"pinned"`
MuteNotifications bool `db:"mute_notifications"`
JoinSource string `db:"join_source"`
@@ -105,6 +109,7 @@ type GroupMemberListOptions struct {
Query string
Cursor string
Limit int
Mentionable bool
}
type GroupInvitation struct {
+3
View File
@@ -1479,6 +1479,9 @@ type FileDef struct {
User string
// Type of the file.
MimeType string
// OriginalName is the client-provided file name. It is not persisted; media handlers use it
// only to keep the public URL extension consistent with the uploaded file.
OriginalName string `db:"-" bson:"-" json:"-"`
// Size of the file in bytes.
Size int64
// Internal file location, i.e. path on disk or an S3 blob address.
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"errors"
"testing"
"github.com/tinode/chat/server/store/types"
)
type staticTenantLoginIPWhitelistProvider struct {
config *tenantLoginIPWhitelistConfig
err error
}
func (p staticTenantLoginIPWhitelistProvider) TenantLoginIPWhitelist(types.TenantID) (*tenantLoginIPWhitelistConfig, error) {
return p.config, p.err
}
func resetTenantLoginIPWhitelistTestState(t *testing.T) {
t.Helper()
previousProvider := loginIPWhitelistProvider
tenantLoginIPWhitelistCache.Lock()
previousCache := tenantLoginIPWhitelistCache.items
tenantLoginIPWhitelistCache.items = make(map[types.TenantID]tenantLoginIPWhitelistCacheEntry)
tenantLoginIPWhitelistCache.Unlock()
t.Cleanup(func() {
loginIPWhitelistProvider = previousProvider
tenantLoginIPWhitelistCache.Lock()
tenantLoginIPWhitelistCache.items = previousCache
tenantLoginIPWhitelistCache.Unlock()
})
}
func TestTenantLoginIPChannel(t *testing.T) {
for platform, expected := range map[string]string{
"web": "web", "WEB": "web", "ios": "app", "android": "app", "": "app",
} {
if actual := tenantLoginIPChannel(platform); actual != expected {
t.Fatalf("platform %q: got %q, want %q", platform, actual, expected)
}
}
}
func TestCheckTenantLoginIPWhitelist(t *testing.T) {
resetTenantLoginIPWhitelistTestState(t)
tenantID := types.TenantID(1)
loginIPWhitelistProvider = staticTenantLoginIPWhitelistProvider{config: &tenantLoginIPWhitelistConfig{
TenantID: tenantID,
AppList: []string{"203.0.113.10", "2001:db8::/32"},
WebList: []string{"198.51.100.0/24"},
}}
if err := checkTenantLoginIPWhitelist(tenantID, "ios", "203.0.113.10:443"); err != nil {
t.Fatalf("APP白名单地址应允许登录: %v", err)
}
if err := checkTenantLoginIPWhitelist(tenantID, "web", "198.51.100.8:443"); err != nil {
t.Fatalf("Web白名单网段应允许登录: %v", err)
}
if err := checkTenantLoginIPWhitelist(tenantID, "web", "203.0.113.10:443"); !errors.Is(err, types.ErrPermissionDenied) {
t.Fatalf("APP地址不应绕过Web白名单: %v", err)
}
}
func TestCheckTenantLoginIPWhitelistEmptyMeansUnrestricted(t *testing.T) {
resetTenantLoginIPWhitelistTestState(t)
tenantID := types.TenantID(1)
loginIPWhitelistProvider = staticTenantLoginIPWhitelistProvider{config: &tenantLoginIPWhitelistConfig{TenantID: tenantID}}
if err := checkTenantLoginIPWhitelist(tenantID, "web", "192.0.2.8:443"); err != nil {
t.Fatalf("未配置渠道白名单时不应限制登录: %v", err)
}
}
func TestCheckTenantLoginIPWhitelistProviderFailureAllowsLogin(t *testing.T) {
resetTenantLoginIPWhitelistTestState(t)
tenantID := types.TenantID(1)
loginIPWhitelistProvider = staticTenantLoginIPWhitelistProvider{err: errors.New("admin timeout")}
if err := checkTenantLoginIPWhitelist(tenantID, "ios", "203.0.113.10:443"); err != nil {
t.Fatalf("白名单配置服务失败时不应阻断登录: %v", err)
}
if err := checkTenantLoginIPWhitelist(tenantID, "web", "198.51.100.8:443"); err != nil {
t.Fatalf("白名单失败空配置应被短暂缓存并继续放行: %v", err)
}
}
+4 -1
View File
@@ -23,7 +23,7 @@
"alias_tag": "alias",
"media": {
"use_handler": "fs",
"use_handler": "sys_config",
"max_size": 104857600,
"gc_period": 3600,
"gc_block_size": 100,
@@ -32,6 +32,9 @@
"upload_dir": "uploads",
"cache_control": "max-age=86400",
"cors_origins": ["*"]
},
"oss": {
"use_sys_config": true
}
}
},
+84 -4
View File
@@ -12,6 +12,7 @@ import (
"context"
"errors"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -1215,6 +1216,18 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return
}
if err := t.validateMessageReplacement(asUid, msg.Pub.Head, msg.Timestamp); err != nil {
if errors.Is(err, types.ErrMalformed) {
msg.sess.queueOut(ErrMalformedReply(msg, types.TimeNow()))
} else {
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
}
return
}
if err := t.consumeRiskActionToken(asUid, msg.Pub.Head); err != nil {
msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow()))
return
}
isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil
if isCall {
@@ -1262,17 +1275,68 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
}
}
func (t *Topic) validateMessageReplacement(asUid types.Uid, head map[string]any, now time.Time) error {
if head == nil || head["replace"] == nil {
return nil
}
replace, ok := head["replace"].(string)
if !ok {
return types.ErrMalformed
}
seq, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(replace), ":"), 10, 64)
if err != nil || seq <= 0 {
return types.ErrMalformed
}
original, err := store.Messages.GetOne(t.tenantID, t.name, seq)
if err != nil {
return err
}
if original == nil || types.ParseUid(original.From) != asUid {
return types.ErrPermissionDenied
}
if now.IsZero() {
now = types.TimeNow()
}
if original.CreatedAt.IsZero() || now.Sub(original.CreatedAt) > 5*time.Minute {
return types.ErrPermissionDenied
}
return nil
}
func (t *Topic) consumeRiskActionToken(asUid types.Uid, head map[string]any) error {
if head == nil {
return nil
}
token, _ := head["risk_action_token"].(string)
challengeID, _ := head["risk_challenge_id"].(string)
token = strings.TrimSpace(token)
challengeID = strings.TrimSpace(challengeID)
if token == "" && challengeID == "" {
return nil
}
if token == "" || challengeID == "" || store.Security == nil {
return types.ErrPermissionDenied
}
if _, err := store.Security.ConsumeActionToken(t.tenantID, asUid, token, "risk_continue", challengeID, types.TimeNow()); err != nil {
return err
}
delete(head, "risk_action_token")
return nil
}
func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) {
mentionAll, _ := head["mention_all"].(bool)
rawMentions, hasMentions := head["mentions"]
if !mentionAll && !hasMentions {
return head, nil
}
pud := t.perUser[asUid]
access := pud.modeGiven & pud.modeWant
if mentionAll && !access.IsAdmin() {
if mentionAll {
if sub, err := store.Subs.Get(t.tenantID, t.name, asUid, false); err != nil {
return nil, err
} else if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsAdmin() {
return nil, types.ErrPermissionDenied
}
}
validMembers := make(map[string]struct{}, len(t.perUser))
for uid, pud := range t.perUser {
mode := pud.modeGiven & pud.modeWant
@@ -2318,11 +2382,27 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
desc.Public = t.public
desc.Trusted = t.trusted
} else if full && t.cat == types.TopicCatP2P {
// FIXME: when a P2P participant updates desc at 'me', these cached values are not updated.
uid1, uid2, err := types.ParseP2P(t.name)
if err != nil {
return err
}
peer := uid1
if peer == asUid {
peer = uid2
}
user, err := store.Users.Get(t.tenantID, peer)
if err != nil {
return err
}
if user != nil {
desc.Public = user.Public
desc.Trusted = user.Trusted
} else {
desc.Public = pud.public
desc.Trusted = pud.trusted
}
}
}
// Request may come from a subscriber (full == true) or a stranger.
// Give subscriber a fuller description than to a stranger/channel reader.
+97
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@@ -493,6 +494,66 @@ func TestHandleBroadcastCall(t *testing.T) {
}
}
func TestHandleBroadcastDraftyCallInvite(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
oldThirdParty := store.ThirdPartyConfig
globals.iceServers = []iceServer{{Username: "dummy"}}
store.ThirdPartyConfig = testThirdPartyConfigStore{
"audio_call_enabled": "1",
"video_call_enabled": "1",
}
helper.topic.lastID = 5
defer func() {
globals.iceServers = nil
store.ThirdPartyConfig = oldThirdParty
helper.tearDown()
}()
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
AsUser: from,
Original: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"webrtc": "started", "mime": "text/x-drafty"},
Content: map[string]any{
"txt": " ",
"ent": []any{map[string]any{
"tp": "VC",
"data": map[string]any{
"call_id": "call-drafty-1",
"aonly": true,
"state": "started",
},
}},
},
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if len(helper.results[1].messages) != 1 {
t.Fatalf("callee: expected 1 message, got %d", len(helper.results[1].messages))
}
data := helper.results[1].messages[0].(*ServerComMessage).Data
content := data.Content.(map[string]any)
if content["callId"] != "call-drafty-1" || content["callType"] != "audio" || content["video"] != false {
t.Fatalf("unexpected normalized Drafty call content: %#v", content)
}
if data.Head["x-im-type"] != "call" || data.Head["mime"] != "text/x-drafty" {
t.Fatalf("unexpected normalized Drafty call head: %#v", data.Head)
}
}
func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
@@ -881,6 +942,9 @@ func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
pud.modeWant = types.ModeCPublic
pud.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[0]] = pud
helper.ss.EXPECT().Get(types.TenantID(1), "grp-test", helper.uids[0], false).Return(&types.Subscription{
TenantID: 1, Topic: "grp-test", User: helper.uids[0].String(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
}, nil)
from := helper.uids[0].UserId()
msg := &ClientComMessage{
@@ -903,6 +967,39 @@ func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
}
}
func TestValidateMessageReplacementChecksAuthorAndWindow(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldMessages := store.Messages
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Messages = messages
t.Cleanup(func() { store.Messages = oldMessages })
now := time.Date(2026, 9, 6, 10, 0, 0, 0, time.UTC)
topic := &Topic{tenantID: 7, name: "grpAlpha"}
author := types.Uid(42)
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(8)).Return(&types.Message{
Topic: "grpAlpha", SeqId: 8, From: author.String(), ObjHeader: types.ObjHeader{CreatedAt: now.Add(-time.Minute)},
}, nil)
if err := topic.validateMessageReplacement(author, map[string]any{"replace": ":8"}, now); err != nil {
t.Fatalf("valid replacement rejected: %v", err)
}
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(9)).Return(&types.Message{
Topic: "grpAlpha", SeqId: 9, From: types.Uid(99).String(), ObjHeader: types.ObjHeader{CreatedAt: now.Add(-time.Minute)},
}, nil)
if err := topic.validateMessageReplacement(author, map[string]any{"replace": ":9"}, now); !errors.Is(err, types.ErrPermissionDenied) {
t.Fatalf("replacement of peer message: got %v", err)
}
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(10)).Return(&types.Message{
Topic: "grpAlpha", SeqId: 10, From: author.String(), ObjHeader: types.ObjHeader{CreatedAt: now.Add(-6 * time.Minute)},
}, nil)
if err := topic.validateMessageReplacement(author, map[string]any{"replace": ":10"}, now); !errors.Is(err, types.ErrPermissionDenied) {
t.Fatalf("expired replacement: got %v", err)
}
}
func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
+6 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"container/heap"
crand "crypto/rand"
"errors"
"math/rand"
"strings"
"time"
@@ -216,7 +217,11 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Create user record in the database.
if _, err := store.Users.Create(s.tenantID, &user, private); err != nil {
logs.Warn.Println("create user: failed to create user", err, "sid=", s.sid)
s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp))
params := map[string]any(nil)
if errors.Is(err, types.ErrDuplicate) && phoneRegistration {
params = map[string]any{"what": "phone", "reason": "PHONE_ALREADY_REGISTERED"}
}
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, params))
return
}
+45
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -65,6 +66,50 @@ func TestConsumeProductVerificationCred(t *testing.T) {
}
}
func TestConsumeProductVerificationCredReturnsDuplicateCredential(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
token := "verified-token"
tokenHash := hashIMSecret(7, "register", "challenge-duplicate", "phone-hash", token)
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-duplicate",
Scene: "register",
PhoneHash: "phone-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().UpsertCred(types.TenantID(7), gomock.Any()).Return(true, types.ErrDuplicate)
store.Users = users
_, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
Method: "tel",
Value: "+8613800138000",
Response: token,
Params: map[string]any{"challenge_id": "challenge-duplicate"},
}, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if !ok || !errors.Is(err, types.ErrDuplicate) {
t.Fatalf("duplicate phone: ok=%v err=%v", ok, err)
}
reply := decodeStoreError(err, "acc-1", time.Now(), nil)
if reply.Ctrl == nil || reply.Ctrl.Code != http.StatusConflict || reply.Ctrl.Text != "duplicate credential" {
t.Fatalf("unexpected duplicate reply: %#v", reply)
}
}
func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) {
oldUsers := store.Users
oldVerifications := store.Verifications