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 }} API_KEY_SALT: ${{ secrets.API_KEY_SALT }}
AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }} AUTH_TOKEN_KEY: ${{ secrets.AUTH_TOKEN_KEY }}
UID_ENCRYPTION_KEY: ${{ secrets.UID_ENCRYPTION_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_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 }} ADMIN_INTERNAL_API_SECRET: ${{ secrets.ADMIN_INTERNAL_API_SECRET }}
INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }} INTERNAL_API_HOTGO_SECRET: ${{ secrets.INTERNAL_API_HOTGO_SECRET }}
run: | 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 ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
# Default handler for large files # Default handler for large files
ENV MEDIA_HANDLER=fs ENV MEDIA_HANDLER=sys_config
# Whitelisted domains for file and S3 large media handler. # Whitelisted domains for file and S3 large media handler.
ENV FS_CORS_ORIGINS='["*"]' ENV FS_CORS_ORIGINS='["*"]'
@@ -223,9 +223,9 @@ RUN mkdir /botdata
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
RUN chmod +x credentials.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 \ 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. # Generate config from template and run the server.
ENTRYPOINT ["./entrypoint.sh"] 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. # DB routing resolves to the same tinode DB for the seeded "default" tenant.
ENV TENANT_REGISTRY_DB=hotgo ENV TENANT_REGISTRY_DB=hotgo
ENV MEDIA_HANDLER=fs ENV MEDIA_HANDLER=sys_config
ENV FS_CORS_ORIGINS='["*"]' ENV FS_CORS_ORIGINS='["*"]'
ENV AWS_CORS_ORIGINS='["*"]' ENV AWS_CORS_ORIGINS='["*"]'
ENV SMTP_HOST_URL='http://localhost:6060' 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 && chmod +x entrypoint.sh credentials.sh tinode init-db apply-hotgo-tenant-db
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \ 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"] ENTRYPOINT ["./entrypoint.sh"]
+7 -4
View File
@@ -34,6 +34,9 @@
"presign_ttl": 3600, "presign_ttl": 3600,
"cache_control": "max-age=86400", "cache_control": "max-age=86400",
"cors_origins": $AWS_CORS_ORIGINS "cors_origins": $AWS_CORS_ORIGINS
},
"oss": {
"use_sys_config": true
} }
} }
}, },
@@ -151,17 +154,17 @@
} }
}, },
"internal_api": { "internal_api": {
"enabled": $INTERNAL_API_ENABLED, "enabled": true,
"time_window_seconds": $INTERNAL_API_TIME_WINDOW_SECONDS, "time_window_seconds": 300,
"services": { "services": {
"$INTERNAL_API_HOTGO_SERVICE_NAME": { "hotgo-admin": {
"secret": "$INTERNAL_API_HOTGO_SECRET", "secret": "$INTERNAL_API_HOTGO_SECRET",
"allow": ["admin_query"] "allow": ["admin_query"]
} }
} }
}, },
"admin_internal_api": { "admin_internal_api": {
"enabled": $ADMIN_INTERNAL_API_ENABLED, "enabled": true,
"base_url": "$ADMIN_INTERNAL_API_BASE_URL", "base_url": "$ADMIN_INTERNAL_API_BASE_URL",
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME", "service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
"secret": "$ADMIN_INTERNAL_API_SECRET", "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` 当前仅为资料字段,不用于登录或找回密码。 - 查询 `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` 并推送资料事件。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。
- 头像、昵称、签名、性别、地区、公开账号等资料字段以 `im_user_profiles` 为业务真源;`im_users.public` 和 `im_subscriptions.public` 只作为服务端生成的展示投影/缓存,前端不得依赖它们作为可编辑源。
- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。
### 10.3 设置昵称.png ### 10.3 设置昵称.png
+2 -2
View File
@@ -508,11 +508,11 @@ end_reason, created_at, updated_at
### 14.1 个人资料 ### 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_user_profiles`,按隐私设置返回 |
| 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 | | 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 |
| 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 | | 邮箱 | `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'); WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_rtc_uid_mode' AND `value` = 'string');
-- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。 -- 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 INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) (`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() 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() 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'); 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 INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) (`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() 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 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 { type adminInternalTenantDTO struct {
ID types.TenantID `json:"id"` ID types.TenantID `json:"id"`
Code string `json:"code"` Code string `json:"code"`
@@ -50,6 +62,7 @@ type adminInternalTenantDTO struct {
func initAdminInternalAPI(raw json.RawMessage) error { func initAdminInternalAPI(raw json.RawMessage) error {
store.SetAdminInternalConfigProvider(nil) store.SetAdminInternalConfigProvider(nil)
loginIPWhitelistProvider = nil
if len(raw) == 0 || string(raw) == "null" { if len(raw) == 0 || string(raw) == "null" {
return nil return nil
} }
@@ -71,10 +84,12 @@ func initAdminInternalAPI(raw json.RawMessage) error {
if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" { if cfg.BaseURL == "" || strings.TrimSpace(cfg.Secret) == "" {
return errors.New("admin_internal_api enabled but base_url or secret is empty") return errors.New("admin_internal_api enabled but base_url or secret is empty")
} }
store.SetAdminInternalConfigProvider(&adminInternalClient{ client := &adminInternalClient{
config: cfg, config: cfg,
http: &http.Client{Timeout: time.Duration(cfg.TimeoutMS) * time.Millisecond}, 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) logs.Info.Printf("Admin internal config client enabled: base_url=%s service=%s", cfg.BaseURL, cfg.ServiceName)
return nil return nil
} }
@@ -152,6 +167,25 @@ func (c *adminInternalClient) ThirdPartyConfig(group string, keys ...string) (ma
return wrapper.Values, nil 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 { func (c *adminInternalClient) getJSON(path string, query url.Values, out any) error {
if c == nil || c.http == nil { if c == nil || c.http == nil {
return types.ErrUnsupported return types.ErrUnsupported
+122
View File
@@ -4,8 +4,10 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"net" "net"
"strings" "strings"
"sync"
"time" "time"
"github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth"
@@ -14,6 +16,18 @@ import (
"github.com/tinode/chat/server/store/types" "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) { 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() { if s == nil || rec == nil || rec.Uid.IsZero() || !s.tenantID.IsValid() {
return 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) { func loginAuditAccount(scheme, supplied, credential string) (string, string) {
if parts := strings.SplitN(strings.TrimSpace(credential), ":", 2); len(parts) == 2 && parts[1] != "" { if parts := strings.SplitN(strings.TrimSpace(credential), ":", 2); len(parts) == 2 && parts[1] != "" {
kind := strings.ToLower(strings.TrimSpace(parts[0])) kind := strings.ToLower(strings.TrimSpace(parts[0]))
+49
View File
@@ -2,6 +2,8 @@ package main
import ( import (
"errors" "errors"
"net/http/httptest"
"reflect"
"strings" "strings"
"testing" "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) { func TestRedactClientMessageForLog(t *testing.T) {
raw := []byte(`{"login":{"id":"1","scheme":"basic","secret":"YWxpY2U6YWxpY2UxMjM="},"acc":{"secret":"account-secret","tmpsecret":"temp-secret"}}`) raw := []byte(`{"login":{"id":"1","scheme":"basic","secret":"YWxpY2U6YWxpY2UxMjM="},"acc":{"secret":"account-secret","tmpsecret":"temp-secret"}}`)
redacted := string(redactClientMessageForLog(raw)) 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" { if state != "started" {
return nil, nil, types.ErrMalformed return nil, nil, types.ErrMalformed
} }
call, ok := content.(map[string]any) call := callInvitePayloadMap(content)
if !ok { if len(call) == 0 || !hasCallIdentity(call) {
if typed, typedOK := content.(types.KVMap); typedOK { headCall := callInvitePayloadMap(head)
call = map[string]any(typed) 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 { if len(call) == 0 {
@@ -123,6 +131,16 @@ func normalizeCallInvite(head map[string]any, content any) (map[string]any, any,
if callType == "" { if callType == "" {
callType, _ = call["media_type"].(string) 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)) callType = strings.ToLower(strings.TrimSpace(callType))
if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) { if strings.TrimSpace(callID) == "" || (callType != constCallTypeAudio && callType != constCallTypeVideo) {
return nil, nil, types.ErrMalformed 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 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 { func callPayloadString(payload any, key string) string {
data, ok := payload.(map[string]any) data, ok := payload.(map[string]any)
if !ok { if !ok {
+75 -14
View File
@@ -149,7 +149,7 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
dsn = cfg.FormatDSN() dsn = cfg.FormatDSN()
dbName = cfg.DBName dbName = cfg.DBName
} }
db, err := sqlx.Open("mysql", dsn) db, err := sqlx.Open(instrumentedMySQLDriverName, dsn)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -165,6 +165,21 @@ func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error
return db, dbName, nil 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 // Open initializes database session
func (a *adapter) Open(jsonconfig json.RawMessage) error { func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.db != nil { if a.db != nil {
@@ -672,7 +687,7 @@ func (a *adapter) CreateDb(reset bool) error {
// Clear database name // Clear database name
cfg.DBName = "" cfg.DBName = ""
a.db, err = sqlx.Open("mysql", cfg.FormatDSN()) a.db, err = sqlx.Open(instrumentedMySQLDriverName, cfg.FormatDSN())
if err != nil { if err != nil {
return err return err
} }
@@ -2490,7 +2505,8 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
var row profileUserRow 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, 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, 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 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) WHERE u.id=? AND u.state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil { 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, 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, 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 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) WHERE u.id IN (?) AND u.state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...) 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. // Fetch p2p users and join to p2p subscriptions.
if len(usrq) > 0 { if len(usrq) > 0 {
q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.public,u.trusted, 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 (?)` 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) q, args, _ = sqlx.In(q, usrq)
if !keepDeleted { 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. // 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, 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 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=?` WHERE s.topic=?`
args := []any{topic} args := []any{topic}
if !keepDeleted { if !keepDeleted {
@@ -3376,18 +3397,21 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
var lastSeen sql.NullTime var lastSeen sql.NullTime
var userAgent string var userAgent string
var public, trusted any var public, trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
for rows.Next() { for rows.Next() {
if err = rows.Scan( if err = rows.Scan(
&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
&sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven, &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 break
} }
sub.User = common.EncodeUidString(sub.User).String() sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private) 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.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub) subs = append(subs, sub)
@@ -4982,6 +5006,11 @@ func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
var fd t.FileDef 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 "+ 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)) "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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -5106,16 +5135,27 @@ func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []
} }
dids = append(dids, store.DecodeUid(id)) 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 { for _, id := range dids {
// createdat,fileid,[msgid|topic|userid] // createdat,fileid,[msgid|topic|userid]
args = append(args, now, id, linkId) args = append(args, now, id, linkId)
} }
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil) tx, err := a.db.BeginTxx(ctx, nil)
if err != nil { if err != nil {
return err return err
@@ -5159,12 +5199,18 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
var allowed int var allowed int
err := a.db.GetContext(ctx, &allowed, ` err := a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM im_fileuploads AS fu 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=? fu.userid=?
OR EXISTS ( OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.fileid=fu.id AND fml.userid=? 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 ( OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s 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 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 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 ( OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m 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) 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 ( OR EXISTS (
SELECT 1 FROM im_meeting_note_files AS mnf SELECT 1 FROM im_meeting_note_files AS mnf
INNER JOIN im_meetings AS mtg INNER JOIN im_meetings AS mtg
@@ -5204,7 +5263,9 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
) )
) )
) LIMIT 1`, ) 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)) fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
if err != nil { if err != nil {
return false, err 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_status(action_status, created_at, id),
INDEX idx_im_message_admin_actor(actor_admin_id, created_at DESC) INDEX idx_im_message_admin_actor(actor_admin_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) 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 { for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil { if _, err := exec.Exec(statement); err != nil {
@@ -13,11 +13,11 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
if err := ensureIMAdminGovernanceTables(exec); err != nil { if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 3 { if len(exec.statements) != 4 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements)) t.Fatalf("created %d admin governance tables, want 4", len(exec.statements))
} }
joined := strings.ToLower(strings.Join(exec.statements, "\n")) 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) { if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", 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), 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) 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`, ) 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 { for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil { 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 { if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 13 { if len(exec.statements) != 14 {
t.Fatalf("created %d group tables, want 13", len(exec.statements)) t.Fatalf("executed %d group schema statements, want 14", len(exec.statements))
} }
for _, statement := range exec.statements { for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") { 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) 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") 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"` Trusted any `db:"trusted"`
ProfileNickname sql.NullString `db:"profile_nickname"` ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"` 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"` ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
} }
@@ -81,7 +85,7 @@ func (row profileUserRow) lastSeen() *time.Time {
} }
func (row profileUserRow) profilePublic() any { 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 { func profileUserFromRow(row profileUserRow) t.User {
@@ -106,7 +110,7 @@ func profileUserFromRow(row profileUserRow) t.User {
return 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) public := publicMap(base)
clearProfilePublicFields(public) clearProfilePublicFields(public)
if nickname.Valid { if nickname.Valid {
@@ -123,11 +127,31 @@ func profilePublicFromFields(base any, nickname, avatarFileID sql.NullString) an
public["avatar_file_id"] = value 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 return public
} }
func clearProfilePublicFields(public map[string]any) { 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) delete(public, key)
} }
} }
@@ -335,7 +359,8 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
var row profileUserRow 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, 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, 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 FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id
WHERE u.id=? AND u.state!=?`, WHERE u.id=? AND u.state!=?`,
id, decodeUID(uid), t.StateDeleted) 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 { 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, 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 (?)` 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) q, args, _ = sqlx.In(q, id, usrq)
if !keepDeleted { if !keepDeleted {
@@ -1051,8 +1077,11 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
} }
tcat := t.GetTopicCat(topic) tcat := t.GetTopicCat(topic)
q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, 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 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=?` WHERE s.tenant_id=? AND s.topic=?`
args := []any{id, topic} args := []any{id, topic}
if !keepDeleted { if !keepDeleted {
@@ -1093,14 +1122,17 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
var sub t.Subscription var sub t.Subscription
var lastSeen sql.NullTime var lastSeen sql.NullTime
var public, trusted any 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, 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, &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 return nil, err
} }
sub.User = common.EncodeUidString(sub.User).String() sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private) 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.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub) subs = append(subs, sub)
@@ -7025,15 +7057,17 @@ func (a *adapter) TenantTopicDraftPut(id t.TenantID, uid t.Uid, topic, content s
return nil, err return nil, err
} }
now := t.TimeNow() 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) (id,user_id,topic,content,entities_json,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),entities_json=VALUES(entities_json), ON DUPLICATE KEY UPDATE id=id`,
version=version+1,updated_at=VALUES(updated_at)`,
draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now) draftID, decodeUID(uid), strings.TrimSpace(topic), content, entities, 1, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrInvalidResponse
}
return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic)) return a.topicDraftByKey(ctx, uid, strings.TrimSpace(topic))
} }
res, err := a.db.ExecContext(ctx, `UPDATE im_topic_drafts 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, err = a.db.GetContext(ctx, &fd,
"SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ "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)) "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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -7760,17 +7801,31 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID,
fids = fids[:1] fids = fids[:1]
} }
var args []any var args []any
var dids []any
for _, fid := range fids { for _, fid := range fids {
fileID := t.ParseUid(fid) fileID := t.ParseUid(fid)
if fileID.IsZero() { if fileID.IsZero() {
return t.ErrMalformed 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() ctx, cancel := a.getContextForTx()
if cancel != nil { if cancel != nil {
defer cancel() 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) tx, err := a.db.BeginTxx(ctx, nil)
if err != nil { if err != nil {
return err return err
@@ -7815,12 +7870,18 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
var allowed int var allowed int
err = a.db.GetContext(ctx, &allowed, ` err = a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM im_fileuploads AS fu 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=? fu.userid=?
OR EXISTS ( OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml SELECT 1 FROM im_filemsglinks AS fml
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=? 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 ( OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_subscriptions AS s 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 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 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 ( OR EXISTS (
SELECT 1 FROM im_filemsglinks AS fml SELECT 1 FROM im_filemsglinks AS fml
INNER JOIN im_messages AS m 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) 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`, ) 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 { if err != nil {
return false, err return false, err
} }
@@ -9163,6 +9239,13 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act
row.HandledAt = &handledAt row.HandledAt = &handledAt
row.UpdatedAt = 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}} { for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
friendshipID, idErr := nextSnowflakeID() friendshipID, idErr := nextSnowflakeID()
if idErr != nil { if idErr != nil {
@@ -9389,7 +9472,8 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted, 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 FROM im_friendships AS fs
JOIN im_users AS u ON u.id=fs.peer_id JOIN im_users AS u ON u.id=fs.peer_id
LEFT JOIN im_user_profiles AS p ON p.user_id=u.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 Trusted any
ProfileNickname sql.NullString `db:"profile_nickname"` ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"` 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"` ProfileUpdatedAt sql.NullTime `db:"profile_updated_at"`
}{} }{}
if err = rows.StructScan(&row); err != nil { 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{ Peer: &t.User{
ObjHeader: t.ObjHeader{Id: peerID.String()}, ObjHeader: t.ObjHeader{Id: peerID.String()},
TenantID: id, 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), 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, 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, 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, " ")+` FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...) WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil { 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 { if opts.Limit <= 0 || opts.Limit > 100 {
opts.Limit = 30 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} args := []any{topic, t.GroupMemberStatusActive}
if opts.Role != "" { if opts.Role != "" {
where = append(where, "role=?") memberIndex = "idx_im_group_members_topic_role"
where = append(where, "gm.role=?")
args = append(args, opts.Role) args = append(args, opts.Role)
} }
if opts.Query != "" { if opts.Query != "" {
where = append(where, "nickname LIKE ?") like := "%" + opts.Query + "%"
args = append(args, "%"+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 != "" { if opts.Cursor != "" {
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
if err != nil || parsed <= 0 { if err != nil || parsed < 0 {
return nil, "", t.ErrMalformed return nil, "", t.ErrMalformed
} }
where = append(where, "id>?") if parsed > 0 {
where = append(where, "gm.id>?")
args = append(args, parsed) 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) args = append(args, opts.Limit+1)
ctx, cancel := a.getContext() ctx, cancel := a.getContext()
if cancel != nil { if cancel != nil {
defer cancel() 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 { if err != nil {
return nil, "", err 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) items := make([]t.GroupMember, 0, opts.Limit)
next := "" next := ""
for rows.Next() { for rows.Next() {
var item t.GroupMember row := struct {
if err = rows.StructScan(&item); err != nil { 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 return nil, "", err
} }
item := row.GroupMember
scanGroupMember(&item) scanGroupMember(&item)
if len(items) == opts.Limit { 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 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) items = append(items, item)
} }
return items, next, rows.Err() return items, next, rows.Err()
@@ -445,7 +479,8 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
return &inv, nil return &inv, nil
} }
var deletedAt sql.NullTime 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 { if err != nil && err != sql.ErrNoRows {
return nil, err return nil, err
} }
@@ -906,6 +941,7 @@ func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cur
next = strconv.FormatInt(file.ID, 10) next = strconv.FormatInt(file.ID, 10)
continue continue
} }
a.enrichGroupFileFromUpload(id, file)
items = append(items, *file) items = append(items, *file)
} }
return items, next, rows.Err() return items, next, rows.Err()
@@ -932,21 +968,93 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
if file.IdempotencyKey == "" { if file.IdempotencyKey == "" {
file.IdempotencyKey = file.GroupFileID file.IdempotencyKey = file.GroupFileID
} }
a.enrichGroupFileFromUpload(id, file)
now := t.TimeNow() now := t.TimeNow()
ctx, cancel := a.getContext() ctx, cancel := a.getContextForTx()
if cancel != nil { if cancel != nil {
defer cancel() 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) (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(?,?,?,?,?,?,?,?,?,?,?,?,?,?) 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.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) file.FileName, file.MIMEType, file.FileType, file.FileSize, file.Status, file.IdempotencyKey, now, now)
if err != nil && !isDupe(err) { if err != nil && !isDupe(err) {
return nil, 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) { 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 { if err = backfillProfileDisplay(ctx, conn, usersScoped); err != nil {
return err return err
} }
if err = backfillUserPublicFromProfile(ctx, conn, usersScoped); err != nil {
return err
}
} }
indexName := "users_phone" indexName := "users_phone"
@@ -464,6 +467,45 @@ func backfillProfileDisplay(ctx context.Context, conn *sqlx.Conn, usersScoped bo
return err 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) { func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, method string) ([]identityValue, error) {
var out []identityValue var out []identityValue
appendQuery := func(query string, args ...any) error { appendQuery := func(query string, args ...any) error {
+12 -7
View File
@@ -34,16 +34,22 @@ func TestProfilePublicFromFields(t *testing.T) {
"nickname": "旧昵称", "nickname": "旧昵称",
"display_name": "旧昵称", "display_name": "旧昵称",
"avatar_file_id": "old_avatar", "avatar_file_id": "old_avatar",
"signature": "旧签名",
"gender": "old_gender",
"region": "old_region",
"photo": "old_photo", "photo": "old_photo",
"public_account": "usrAccount", "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"} { for _, key := range []string{"fn", "name", "nickname", "display_name"} {
if public[key] != "新昵称" { if public[key] != "新昵称" {
t.Fatalf("nickname field %s not overlaid: %#v", key, public) 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) t.Fatalf("profile public not overlaid: %#v", public)
} }
@@ -51,15 +57,14 @@ func TestProfilePublicFromFields(t *testing.T) {
"fn": "旧昵称", "fn": "旧昵称",
"avatar_file_id": "old_avatar", "avatar_file_id": "old_avatar",
"public_account": "usrAccount", "public_account": "usrAccount",
}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any) "signature": "旧签名",
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar"} { }, 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 { if _, ok := public[key]; ok {
t.Fatalf("legacy profile field %s should not be read from im_users.public: %#v", key, public) 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) { func TestProfileSeedFromPublic(t *testing.T) {
+7 -4
View File
@@ -27,6 +27,7 @@ import (
"github.com/tinode/chat/pbx" "github.com/tinode/chat/pbx"
"github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
"google.golang.org/grpc/peer" "google.golang.org/grpc/peer"
@@ -310,15 +311,16 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
return return
} }
mimeType := http.DetectContentType(buff) detectedMimeType := http.DetectContentType(buff)
clientContentType := ""
// If DetectContentType fails, see if client-provided content type can be used. // 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 { if userContentType, params, err := mime.ParseMediaType(header.Header.Get("Content-Type")); err == nil {
// Make sure the content-type is legit. // Make sure the content-type is legit.
for _, allowed := range allowedMimeTypes { for _, allowed := range allowedMimeTypes {
if strings.HasPrefix(userContentType, allowed) { if strings.HasPrefix(userContentType, allowed) {
if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" { if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" {
mimeType = userContentType clientContentType = userContentType
} }
break break
} }
@@ -332,7 +334,8 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
}, },
TenantID: principal.TenantID, TenantID: principal.TenantID,
User: principal.UID.String(), User: principal.UID.String(),
MimeType: mimeType, MimeType: media.MimeTypeForUpload(detectedMimeType, clientContentType, header.Filename),
OriginalName: header.Filename,
} }
fdef.InitTimes() fdef.InitTimes()
+2 -2
View File
@@ -34,7 +34,7 @@ func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (
data, _ := io.ReadAll(file) data, _ := io.ReadAll(file)
h.def = def h.def = def
h.data = string(data) h.data = string(data)
def.Location = "/v0/file/s/" + def.Uid().String() + ".txt" def.Location = "/v0/file/s/" + media.FileURLName(def)
def.ETag = "etag" def.ETag = "etag"
return def.Location, int64(len(data)), nil 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" { params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" {
t.Fatalf("unexpected upload params: %#v", params) 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) t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes)
} }
} }
+350 -61
View File
@@ -62,7 +62,7 @@ const (
imVerificationResendAfterSeconds = 60 imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5 imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour imVerificationCreateRateWindow = time.Hour
imSensitiveChallengeRateLimitEnabled = false imSensitiveChallengeRateLimitEnabled = true
imActionTokenExpiresInSeconds = 300 imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5 imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300 imIndependentPasswordLockSeconds = 300
@@ -408,7 +408,7 @@ type imMessageRevisionRequest struct {
type imFavoritesBatchCreateRequest struct { type imFavoritesBatchCreateRequest struct {
Messages []struct { Messages []struct {
Topic string `json:"topic"` Topic string `json:"topic"`
Seq int64 `json:"seq,string"` Seq imForwardSeq `json:"seq"`
} `json:"messages"` } `json:"messages"`
} }
@@ -1503,7 +1503,7 @@ func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
return return
} }
action := strings.TrimSpace(body.Scene) 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) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return 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)}) meetingParticipantUserIDs(participants), "会议提醒", meetingID, map[string]any{"meeting_id": meetingID, "reminded_count": len(participants)})
writeIMSuccess(wrt, req, map[string]any{ writeIMSuccess(wrt, req, map[string]any{
"meeting_id": meetingID, "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 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{ profile, err := store.Profiles.Update(principal.TenantID, principal.Uid, types.UserProfilePatch{
AvatarFileID: body.AvatarFileID, AvatarFileID: body.AvatarFileID,
Nickname: body.Nickname, Nickname: body.Nickname,
@@ -3557,37 +3566,39 @@ func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) {
failed := make([]map[string]any, 0) failed := make([]map[string]any, 0)
for _, src := range body.Messages { for _, src := range body.Messages {
sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic) sourceTopic, sourceOK := normalizeIMFavoriteSourceTopic(principal.Uid, src.Topic)
if !sourceOK || src.Seq <= 0 { seq := int64(src.Seq)
failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest}) if !sourceOK || seq <= 0 {
failed = append(failed, map[string]any{"topic": src.Topic, "seq": seq, "reason": imReasonMalformedRequest})
continue continue
} }
if err := requireTopicReader(principal, sourceTopic); err != nil { 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 continue
} }
msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, src.Seq) msg, err := store.Messages.GetOne(principal.TenantID, sourceTopic, seq)
if err != nil { 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 continue
} }
idempotencyKey := favoriteItemIdempotencyKey(req.Header.Get("Idempotency-Key"), sourceTopic, seq)
fav := &types.FavoriteItem{ fav := &types.FavoriteItem{
TenantID: principal.TenantID, TenantID: principal.TenantID,
FavoriteID: "fav_" + store.Store.GetUidString(), FavoriteID: "fav_" + store.Store.GetUidString(),
UserID: principal.Uid, UserID: principal.Uid,
ItemType: imMessageType(msg), ItemType: imMessageType(msg),
SourceTopic: sourceTopic, SourceTopic: sourceTopic,
SourceSeq: src.Seq, SourceSeq: seq,
Snapshot: favoriteSnapshot(msg), Snapshot: favoriteSnapshot(msg),
AttachmentRefs: favoriteAttachmentRefs(msg), AttachmentRefs: favoriteAttachmentRefs(msg),
Availability: messageAvailability(*msg), Availability: messageAvailability(*msg),
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), IdempotencyKey: idempotencyKey,
CollectedAt: now, CollectedAt: now,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
item, err := store.Favorites.Create(principal.TenantID, fav) item, err := store.Favorites.Create(principal.TenantID, fav)
if err != nil { 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 continue
} }
if item.FavoriteID == fav.FavoriteID { 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)) results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs))
comment := strings.TrimSpace(body.Comment) comment := strings.TrimSpace(body.Comment)
for _, target := range body.TargetTopics { for _, target := range body.TargetTopics {
target = strings.TrimSpace(target) rawTarget := strings.TrimSpace(target)
if target == "" { target, targetOK := normalizeIMForwardTopic(principal.Uid, rawTarget)
results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest}) if target == "" || !targetOK {
results = append(results, map[string]any{"target_topic": rawTarget, "status": "failed", "reason": imReasonMalformedRequest})
continue continue
} }
if err := requireTopicWriter(principal, target); err != nil { if err := requireTopicWriter(principal, target); err != nil {
@@ -3723,6 +3735,10 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
}, },
Content: favoriteForwardContent(item, comment), 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) saved, err := saveMessageMaybeKafka(req.Context(), principal.TenantID, msg, nil, true)
if err != nil { if err != nil {
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) 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) writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil)
return return
} }
principal, _, ok := authenticateTopicReader(wrt, req, topic) principal, normalizedTopic, ok := authenticateTopicReader(wrt, req, topic)
if !ok { if !ok {
return return
} }
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100) before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 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 { if err != nil {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
@@ -4036,7 +4052,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
for _, msg := range msgs { for _, msg := range msgs {
items = append(items, messageContextPayload(msg)) 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) { 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) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return 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) privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target)
if err != nil { if err != nil {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
@@ -4414,8 +4484,8 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
ApplicantID: principal.Uid, ApplicantID: principal.Uid,
TargetID: target, TargetID: target,
Message: message, Message: message,
Source: strings.TrimSpace(body.Source), Source: source,
SourceTopic: strings.TrimSpace(body.SourceTopic), SourceTopic: sourceTopic,
Status: types.FriendRequestPending, Status: types.FriendRequestPending,
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
ExpiresAt: now.Add(7 * 24 * time.Hour), 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 { if item.Friendship.PeerID == principal.Uid {
continue 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}) 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, CreatedAt: now,
UpdatedAt: 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)) memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs))
seen := map[types.Uid]bool{principal.Uid: true} seen := map[types.Uid]bool{principal.Uid: true}
for _, rawID := range body.MemberUserIDs { for _, rawID := range body.MemberUserIDs {
@@ -4732,8 +4808,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
return return
} }
limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100) limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100)
cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor")) cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
if err != nil { 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"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return return
} }
@@ -4747,25 +4829,22 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
writeIMSuccess(wrt, req, cached) writeIMSuccess(wrt, req, cached)
return 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 { if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
items := make([]map[string]any, 0, limit) items := make([]map[string]any, 0, limit)
seen := 0
nextCursor := ""
for _, member := range members { for _, member := range members {
access := member.ModeGiven & member.ModeWant access := groupMemberAccess(member)
mentionable := access.IsJoiner() && access.IsReader() item := groupBusinessMemberPayload(member)
role := groupMemberRole(access)
if roleFilter != "" && role != roleFilter {
continue
}
if mentionableOnly && !mentionable {
continue
}
item := groupMemberPayload(member, mentionable)
if eligibleRole != "" { if eligibleRole != "" {
eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole) eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole)
item["eligible"] = eligible item["eligible"] = eligible
@@ -4780,19 +4859,7 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
item["ineligible_reason"] = reason 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) items = append(items, item)
seen++
} }
permissions := map[string]any{ permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(), "can_mention_all": selfAccess.IsAdmin(),
@@ -4866,7 +4933,14 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
public["fn"] = name public["fn"] = name
} }
if body.AvatarFileID != nil { 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 { if body.Description != nil {
description := strings.TrimSpace(*body.Description) description := strings.TrimSpace(*body.Description)
@@ -5016,7 +5090,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return 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) { 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()) 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) dispatchIMGroupEvents(principal.TenantID)
@@ -5107,6 +5181,17 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
return return
} }
access := selfSub.ModeGiven & selfSub.ModeWant 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() { if !access.IsSharer() && !access.IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return return
@@ -5210,6 +5295,17 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
"has_more": nextCursor != "", "has_more": nextCursor != "",
}) })
case tail == "files" && req.Method == http.MethodPost: 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) idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
if !ok { if !ok {
return return
@@ -5218,7 +5314,7 @@ func serveIMGroupFiles(wrt http.ResponseWriter, req *http.Request, topic, tail s
if !decodeIMJSONBody(wrt, req, &body) { if !decodeIMJSONBody(wrt, req, &body) {
return return
} }
fileID := strings.TrimSpace(body.FileID) fileID := normalizeIMUploadedFileID(body.FileID)
if fileID == "" { if fileID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"})
return 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) writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return 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) sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil { if err != nil {
writeIMRelationError(wrt, req, err) 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 { func parseIMLimit(raw string, fallback, max int) int {
limit := fallback limit := fallback
if raw != "" { if raw != "" {
@@ -7177,6 +7320,38 @@ func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any
return data 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 { func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any {
data := groupMemberPayload(*sub, true) data := groupMemberPayload(*sub, true)
data["topic"] = topic data["topic"] = topic
@@ -7304,7 +7479,9 @@ func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSett
payload["description"] = settings.Description payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId() payload["owner"] = settings.OwnerUserID.UserId()
payload["member_count"] = settings.MemberCount payload["member_count"] = settings.MemberCount
if _, exists := payload["version"]; !exists {
payload["version"] = settings.Version payload["version"] = settings.Version
}
return payload 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, 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{ return strings.Join([]string{
topic, topic,
"v3",
uid.UserId(), uid.UserId(),
access.String(), access.String(),
strings.ToLower(strings.TrimSpace(query)), strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter), strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole), strings.TrimSpace(eligibleRole),
strconv.Itoa(limit), strconv.Itoa(limit),
strconv.Itoa(cursor), strings.TrimSpace(cursor),
strconv.FormatBool(mentionableOnly), strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible), 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 { func meetingNotesDetailPayload(tenantID types.TenantID, meeting *types.Meeting, files []types.MeetingNoteFile, viewer types.Uid) map[string]any {
userSummaries := meetingUserSummaries(tenantID, []types.Meeting{*meeting}) 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)) fileItems := make([]map[string]any, 0, len(files))
for i := range files { for i := range files {
fileItems = append(fileItems, meetingNoteFilePayload(&files[i], viewer, userSummaries)) 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{ "field_rules": map[string]any{
"nickname": map[string]any{"min_length": 1, "max_length": 32}, "nickname": map[string]any{"min_length": 1, "max_length": 32},
"signature": map[string]any{"max_length": 120}, "signature": map[string]any{"max_length": 120},
"region": map[string]any{"max_length": 128},
"birth_date": map[string]any{"format": "YYYY-MM-DD"}, "birth_date": map[string]any{"format": "YYYY-MM-DD"},
"gender": []string{"male", "female", "other", ""}, "gender": []string{"male", "female", "other", ""},
"email": map[string]any{"max_length": 128}, "email": map[string]any{"max_length": 128},
@@ -8630,6 +8820,15 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any {
return data 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 { func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any {
return map[string]any{ return map[string]any{
"add_me_methods": settings.AddMeMethods, "add_me_methods": settings.AddMeMethods,
@@ -8954,13 +9153,20 @@ func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]a
return data 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{ data := map[string]any{
"user_id": item.Friendship.PeerID.UserId(), "user_id": item.Friendship.PeerID.UserId(),
"relationship": item.Friendship.Status, "relationship": item.Friendship.Status,
"remark": item.Friendship.Remark, "remark": item.Friendship.Remark,
"topic": item.Friendship.Topic, "topic": item.Friendship.Topic,
"online_visibility": "unknown", "online": online,
"online_visibility": onlineVisibility,
"created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano), "created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": item.Friendship.UpdatedAt.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)}, "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[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"] { if item.Friendship.Remark == "" && data["display_name"] == data["user_id"] {
data["display_name_source"] = "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) 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) { func normalizeIMUserVisibleTopic(viewer types.Uid, topic string) (string, bool) {
topic = strings.TrimSpace(topic) topic = strings.TrimSpace(topic)
if topic == "" { if topic == "" {
return "", false return "", false
} }
if topic == "slf" {
return viewer.SlfName(), !viewer.IsZero()
}
if len(topic) < 3 { if len(topic) < 3 {
return "", false return "", false
} }
@@ -9419,7 +9640,7 @@ func buildForwardBundle(principal *imPrincipal, sources []struct {
if len(failures) > 0 { if len(failures) > 0 {
return nil, failures return nil, failures
} }
userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages) userSummaries := forwardMessageUserSummaries(principal.TenantID, sourceMessages, forwardBundleTitleUIDs(sourceTopics)...)
items := make([]map[string]any, 0, len(sourceMessages)) items := make([]map[string]any, 0, len(sourceMessages))
preview := make([]map[string]any, 0, min(len(sourceMessages), 3)) preview := make([]map[string]any, 0, min(len(sourceMessages), 3))
sourceRefs := make([]map[string]any, 0, len(sourceMessages)) sourceRefs := make([]map[string]any, 0, len(sourceMessages))
@@ -9447,7 +9668,7 @@ func buildForwardBundle(principal *imPrincipal, sources []struct {
bundleID := "fbundle_" + store.Store.GetUidString() bundleID := "fbundle_" + store.Store.GetUidString()
return map[string]any{ return map[string]any{
"bundle_id": bundleID, "bundle_id": bundleID,
"title": "聊天记录", "title": forwardBundleTitle(principal.TenantID, sourceTopics, userSummaries),
"item_count": len(items), "item_count": len(items),
"preview": preview, "preview": preview,
"items": items, "items": items,
@@ -9457,12 +9678,80 @@ func buildForwardBundle(principal *imPrincipal, sources []struct {
}, nil }, nil
} }
func forwardMessageUserSummaries(tenantID types.TenantID, messages []*types.Message) map[types.Uid]map[string]any { func forwardBundleTitleUIDs(sourceTopics []string) []types.Uid {
if store.Users == nil || len(messages) == 0 { 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 return nil
} }
seen := make(map[types.Uid]bool) 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 { for _, msg := range messages {
if msg == nil { if msg == nil {
continue continue
+359 -33
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -278,6 +279,7 @@ type testMeetingNotesStore struct {
createdMeetingID string createdMeetingID string
createdFileID string createdFileID string
createdIdempotencyKey string createdIdempotencyKey string
createdFile *types.MeetingNoteFile
} }
type testDailyReportsStore struct { type testDailyReportsStore struct {
@@ -343,6 +345,9 @@ type testGroupBusinessStore struct {
mutes []types.GroupMute mutes []types.GroupMute
files []types.GroupFile files []types.GroupFile
invitations []types.GroupInvitation invitations []types.GroupInvitation
listMembers []types.GroupMember
listMembersOpts types.GroupMemberListOptions
listMembersNext string
updatedSettings types.GroupSettingsPatch updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch updatedMembers []types.GroupMemberPatch
updatedRoleMode types.AccessMode 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 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) { func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
return nil, "", nil 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) { 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.createdMeetingID = meetingID
s.createdFileID = fileID s.createdFileID = fileID
s.createdIdempotencyKey = idempotencyKey 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, NoteFileID: noteFileID, MeetingID: meetingID, FileID: fileID, UploaderID: uid,
FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native", FileName: fileID, Status: types.MeetingNoteFileStatusActive, PreviewStatus: "ready", PreviewMode: "native",
IdempotencyKey: idempotencyKey, Version: 1, CreatedAt: now, UpdatedAt: now, 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) { 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) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42) expectIMBearer(t, ctrl, "token-1", 7, 42)
@@ -2396,11 +2409,26 @@ func TestServeIMSecurityChallengeCreateSkipsRateLimitWhenDisabled(t *testing.T)
serveIMSecurityChallenges(rec, req) serveIMSecurityChallenges(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) 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" { if verifications.created != nil {
t.Fatalf("expected a new challenge while sensitive challenge rate limit is disabled: %#v", verifications.created) 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) data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
files := data["files"].([]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) t.Fatalf("unexpected meeting notes detail: %#v", data)
} }
} }
@@ -3007,9 +3037,30 @@ func TestServeIMMeetingNoteFilesCreate(t *testing.T) {
viewer := types.Uid(42) viewer := types.Uid(42)
mockStore := expectIMBearer(t, ctrl, "token-1", 7, viewer) mockStore := expectIMBearer(t, ctrl, "token-1", 7, viewer)
mockStore.EXPECT().GetUidString().Return("note-1") 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 oldMeetingNotes := store.MeetingNotes
store.MeetingNotes = &testMeetingNotesStore{} notes := &testMeetingNotesStore{}
store.Meetings = &testMeetingsStore{meeting: &meeting}
store.MeetingNotes = notes
defer func() { defer func() {
store.Meetings = oldMeetings
store.MeetingNotes = oldMeetingNotes 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 { 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) 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) { func TestServeIMDailyReportsCreateAndList(t *testing.T) {
@@ -3802,14 +3881,14 @@ func TestServeIMGroupMembers(t *testing.T) {
defer ctrl.Finish() defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42) expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs oldSubs := store.Subs
oldTopics := store.Topics oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs store.Subs = subs
store.Topics = topics groupStore := &testGroupBusinessStore{listMembersNext: "101"}
store.GroupBusiness = groupStore
defer func() { defer func() {
store.Subs = oldSubs store.Subs = oldSubs
store.Topics = oldTopics store.GroupBusiness = oldGroupBusiness
}() }()
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ 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, ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, nil)
alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} groupStore.listMembers = []types.GroupMember{{
alice.SetPublic(map[string]any{"display_name": "Alice"}) ID: 101,
bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} Topic: "grpMention",
bob.SetPublic(map[string]any{"display_name": "Bob"}) UserID: types.Uid(101),
topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil) 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") req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -3836,12 +3920,21 @@ func TestServeIMGroupMembers(t *testing.T) {
} }
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]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) 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 { if data["permissions"].(map[string]any)["can_mention_all"] != true {
t.Fatalf("expected can_mention_all permission: %#v", data) 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) { func TestGroupMemberPayloadUserIDFormats(t *testing.T) {
@@ -4002,6 +4095,7 @@ func TestServeIMGroupCreate(t *testing.T) {
defer ctrl.Finish() defer ctrl.Finish()
oldStore := store.Store oldStore := store.Store
oldGroupBusiness := store.GroupBusiness oldGroupBusiness := store.GroupBusiness
oldFiles := store.Files
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
@@ -4014,23 +4108,27 @@ func TestServeIMGroupCreate(t *testing.T) {
oldSubs := store.Subs oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Topics = topics store.Topics = topics
store.Subs = subs store.Subs = subs
store.Files = files
groupBusiness := &testGroupBusinessStore{} groupBusiness := &testGroupBusinessStore{}
store.GroupBusiness = groupBusiness store.GroupBusiness = groupBusiness
defer func() { defer func() {
store.Store = oldStore store.Store = oldStore
store.Topics = oldTopics store.Topics = oldTopics
store.Subs = oldSubs store.Subs = oldSubs
store.Files = oldFiles
store.GroupBusiness = oldGroupBusiness store.GroupBusiness = oldGroupBusiness
}() }()
avatarID := types.Uid(101).String()
topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{ topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpnewgroup"}, ObjHeader: types.ObjHeader{Id: "grpnewgroup"},
TenantID: 7, TenantID: 7,
Owner: types.Uid(42).String(), Owner: types.Uid(42).String(),
SubCnt: 2, 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) }, nil)
subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{ subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7, TenantID: 7,
@@ -4039,8 +4137,9 @@ func TestServeIMGroupCreate(t *testing.T) {
ModeWant: types.ModeCFull, ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, 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") req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -4150,6 +4249,7 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
defer ctrl.Finish() defer ctrl.Finish()
oldStore := store.Store oldStore := store.Store
oldGroupBusiness := store.GroupBusiness oldGroupBusiness := store.GroupBusiness
oldFiles := store.Files
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
@@ -4161,13 +4261,16 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
oldSubs := store.Subs oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Topics = topics store.Topics = topics
store.Subs = subs store.Subs = subs
store.Files = files
store.GroupBusiness = &testGroupBusinessStore{updateSettingsErr: types.ErrNotFound} store.GroupBusiness = &testGroupBusinessStore{updateSettingsErr: types.ErrNotFound}
defer func() { defer func() {
store.Store = oldStore store.Store = oldStore
store.Topics = oldTopics store.Topics = oldTopics
store.Subs = oldSubs store.Subs = oldSubs
store.Files = oldFiles
store.GroupBusiness = oldGroupBusiness store.GroupBusiness = oldGroupBusiness
}() }()
selfSub := &types.Subscription{ selfSub := &types.Subscription{
@@ -4185,9 +4288,11 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
SubCnt: 3, SubCnt: 3,
Public: map[string]any{"name": "Alpha", "version": 1}, Public: map[string]any{"name": "Alpha", "version": 1},
} }
avatarID := types.Uid(100).String()
var savedPublic map[string]any var savedPublic map[string]any
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, 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( topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).DoAndReturn(
func(_ types.TenantID, _ string, update map[string]any) error { func(_ types.TenantID, _ string, update map[string]any) error {
public, ok := update["Public"].(map[string]any) 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") req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
serveIMGroupResource(rec, req) serveIMGroupResource(rec, req)
@@ -4218,7 +4323,7 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String()) 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) 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, TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil).Times(3) }, nil).Times(3)
fileID := types.Uid(101).String()
createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files", 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("Authorization", "Bearer token-1")
createReq.Header.Set("Idempotency-Key", "gfile-1") createReq.Header.Set("Idempotency-Key", "gfile-1")
createRec := httptest.NewRecorder() 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()) t.Fatalf("create status: got %d body=%s", createRec.Code, createRec.Body.String())
} }
created := decodeIMHTTPResponse(t, createRec).Data.(map[string]any) 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) 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) { func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() 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) { func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -5369,16 +5608,20 @@ func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
oldSubs := store.Subs oldSubs := store.Subs
oldMessages := store.Messages oldMessages := store.Messages
oldUsers := store.Users oldUsers := store.Users
oldTopics := store.Topics
subs := mock_store.NewMockSubsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
users := mock_store.NewMockUsersPersistenceInterface(ctrl) users := mock_store.NewMockUsersPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs store.Subs = subs
store.Messages = messages store.Messages = messages
store.Users = users store.Users = users
store.Topics = topics
t.Cleanup(func() { t.Cleanup(func() {
store.Subs = oldSubs store.Subs = oldSubs
store.Messages = oldMessages store.Messages = oldMessages
store.Users = oldUsers store.Users = oldUsers
store.Topics = oldTopics
}) })
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{ 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, TenantID: 7,
Public: types.KVMap{"display_name": "后端-potter", "avatar_file_id": "avatar-99"}, Public: types.KVMap{"display_name": "后端-potter", "avatar_file_id": "avatar-99"},
}}, nil) }}, 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( messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn(
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
if msg.Topic != "grpTarget" || msg.Head["x-im-type"] != "forward_bundle" { 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 { if content["bundle_id"] != "fbundle_bundle-1" || content["item_count"] != 1 {
t.Fatalf("unexpected bundle content: %#v", content) t.Fatalf("unexpected bundle content: %#v", content)
} }
if content["title"] != "研发群的聊天记录" {
t.Fatalf("unexpected bundle title: %#v", content)
}
bundleItems := content["items"].([]map[string]any) bundleItems := content["items"].([]map[string]any)
itemUser := bundleItems[0]["from_user"].(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" { 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) { func TestServeIMSecurityURLCheck(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -5694,10 +5968,10 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
oldStore := store.Store oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). 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 store.Store = mockStore
oldSubs := store.Subs oldSubs := store.Subs
oldMessages := store.Messages oldMessages := store.Messages
@@ -5717,10 +5991,11 @@ func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
Topic: normalizedTopic, Topic: normalizedTopic,
ModeWant: types.ModeCPublic, ModeWant: types.ModeCPublic,
ModeGiven: 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": "好的"}} 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"}} 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().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) 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) 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()) 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 := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/media?type=image", nil)
mediaReq.Header.Set("Authorization", "Bearer token-1") mediaReq.Header.Set("Authorization", "Bearer token-1")
mediaRec := httptest.NewRecorder() mediaRec := httptest.NewRecorder()
@@ -6629,7 +6916,7 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
mockStore.EXPECT().GetUidString().Return("request-1") mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations oldRelations := store.Relations
oldProfiles := store.Profiles oldProfiles := store.Profiles
relations := &testRelationsStore{} relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Relations = relations store.Relations = relations
store.Profiles = &testProfilesStore{} store.Profiles = &testProfilesStore{}
defer func() { 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) { func TestServeIMFriendRequestList(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+53 -4
View File
@@ -270,12 +270,25 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
func usersInternalQuerySpec() *internalQuerySpec { func usersInternalQuerySpec() *internalQuerySpec {
return &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, 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`, (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", DefaultSort: "ORDER BY u.id DESC",
DetailKey: "u.id", 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_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')`, 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", 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 { if err != nil {
return "", "", nil, 0, 0, err 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 op := filter.Op
if op == "" { if op == "" {
op = "=" op = "="
@@ -418,6 +453,20 @@ func internalAdminFilterValue(_ string, raw, kind string) (any, error) {
return id, nil return id, nil
case "int": case "int":
return strconv.ParseInt(raw, 10, 64) 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: default:
return raw, nil return raw, nil
} }
+83 -2
View File
@@ -18,6 +18,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "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) 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) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return return
} }
@@ -97,6 +98,10 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
if !ok { if !ok {
return 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 { switch {
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "overview": 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", "") serveInternalAdminResourceList(wrt, req, tenant.ID, "private-messages", "")
case len(parts) == 3 && parts[2] == "group-messages": case len(parts) == 3 && parts[2] == "group-messages":
serveInternalAdminResourceList(wrt, req, tenant.ID, "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", "") serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users": case len(parts) == 4 && parts[2] == "users":
serveInternalUserQuery(wrt, req, tenant.ID, parts[3]) 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 { func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
if !internalAPI.config.Enabled { if !internalAPI.config.Enabled {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
+59
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -67,6 +68,9 @@ func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantI
SeqID: msg.SeqId, SeqID: msg.SeqId,
UserID: types.ParseUid(msg.From).UserId(), UserID: types.ParseUid(msg.From).UserId(),
FileType: lifecycleFileTypeFromMessageHead(msg.Head), 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, OccurredAt: msg.CreatedAt,
} }
return enqueueKafkaFileLifecycle(ctx, tenantID, kafkaFileLifecycleAttachmentsBind, fmt.Sprintf("%s:%d", msg.Topic, msg.SeqId), payload) 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, SourceTopic: payload.Topic,
SourceSeq: payload.SeqID, SourceSeq: payload.SeqID,
UploaderID: uploader, UploaderID: uploader,
FileName: payload.FileName,
MIMEType: payload.MIMEType,
FileType: lifecycleFileType(payload.FileType), FileType: lifecycleFileType(payload.FileType),
FileSize: payload.FileSize,
IdempotencyKey: fmt.Sprintf("%s:%d:%s", payload.Topic, payload.SeqID, fid.String()), IdempotencyKey: fmt.Sprintf("%s:%d:%s", payload.Topic, payload.SeqID, fid.String()),
}) })
if err != nil && !errors.Is(err, types.ErrDuplicate) { if err != nil && !errors.Is(err, types.ErrDuplicate) {
@@ -183,6 +190,58 @@ func lifecycleHeaderString(head types.KVMap, key string) string {
return strings.TrimSpace(str) 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 { func lifecycleCanonicalMessageType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) { switch strings.ToLower(strings.TrimSpace(raw)) {
case "audio": case "audio":
+56 -6
View File
@@ -13,6 +13,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"flag" "flag"
"net/http" "net/http"
"os" "os"
@@ -254,6 +255,52 @@ type mediaConfig struct {
Handlers map[string]json.RawMessage `json:"handlers"` 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 // Contentx of the configuration file
type configType struct { type configType struct {
// HTTP(S) address:port to listen on for websocket and long polling clients. Either a // HTTP(S) address:port to listen on for websocket and long polling clients. Either a
@@ -640,12 +687,13 @@ func main() {
} else { } else {
globals.maxFileUploadSize = config.Media.MaxFileUploadSize globals.maxFileUploadSize = config.Media.MaxFileUploadSize
if config.Media.Handlers != nil { if config.Media.Handlers != nil {
var conf string handler, conf, err := resolveMediaHandlerConfig(config.Media)
if params := config.Media.Handlers[config.Media.UseHandler]; params != nil { if err != nil {
conf = string(params) logs.Err.Fatalf("Failed to resolve media handler config: %s", err)
} }
if err = store.Store.UseMediaHandler(config.Media.UseHandler, conf); err != nil { config.Media.UseHandler = handler
logs.Err.Fatalf("Failed to init media handler '%s': %s", config.Media.UseHandler, err) 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 { 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) logs.Info.Printf("Server status is available at '%s'", sspath)
mux.HandleFunc(sspath, serveStatus) mux.HandleFunc(sspath, serveStatus)
} }
mux.HandleFunc("/healthz", serveHealthz)
mux.HandleFunc("/metrics", serveMetrics)
// Handle websocket clients. // Handle websocket clients.
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket) mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
@@ -898,7 +948,7 @@ func main() {
mux.HandleFunc("/", serve404) 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) 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 return "", 0, err
} }
fname := fdef.Id fname := media.FileURLName(fdef)
fname += media.ExtensionByMimeType(fdef.MimeType)
fdef.Location = location fdef.Location = location
// Use file path to create ETag. File paths are unique so will be the ETag. // 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 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. // ExtensionByMimeType returns a stable URL extension for uploaded media.
func ExtensionByMimeType(mimeType string) string { func ExtensionByMimeType(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
+70
View File
@@ -3,6 +3,8 @@ package media
import ( import (
"strings" "strings"
"testing" "testing"
"github.com/tinode/chat/server/store/types"
) )
func TestMatchCORSOrigin(t *testing.T) { 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_id",
"oss_access_key_secret", "oss_access_key_secret",
"oss_presign_ttl", "oss_presign_ttl",
"oss_serve_url",
"oss_cache_control",
"oss_cors_origins",
"oss_verify_bucket",
) )
if err != nil { if err != nil {
return errors.New("failed to load OSS config from sys config: " + err.Error()) 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.RegionID, values["oss_region_id"])
applyString(&oh.conf.AccessKeyID, values["oss_access_key_id"]) applyString(&oh.conf.AccessKeyID, values["oss_access_key_id"])
applyString(&oh.conf.AccessKeySecret, values["oss_access_key_secret"]) 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 != "" { if ttl := strings.TrimSpace(values["oss_presign_ttl"]); ttl != "" {
parsed, err := strconv.Atoi(ttl) parsed, err := strconv.Atoi(ttl)
if err != nil { if err != nil {
@@ -156,6 +168,30 @@ func (oh *ossHandler) loadSysConfig() error {
return nil 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) { func applyString(target *string, value string) {
if value = strings.TrimSpace(value); value != "" { if value = strings.TrimSpace(value); value != "" {
*target = value *target = value
@@ -194,20 +230,23 @@ func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.U
"Cache-Control": {oh.conf.CacheControl}, "Cache-Control": {oh.conf.CacheControl},
}, http.StatusNotModified, nil }, http.StatusNotModified, nil
} }
objectKey, err := oh.resolveObjectKey(fdef.Location)
if err != nil {
return nil, 0, err
}
var redirURL string var redirURL string
switch method { switch method {
case http.MethodGet: case http.MethodGet:
options := []aliyunoss.Option{ options := []aliyunoss.Option{
aliyunoss.ResponseCacheControl(oh.conf.CacheControl), aliyunoss.ResponseCacheControl(oh.conf.CacheControl),
aliyunoss.ResponseContentType(fdef.MimeType),
} }
if isAttachment, _ := strconv.ParseBool(url.Query().Get("asatt")); isAttachment { if isAttachment, _ := strconv.ParseBool(url.Query().Get("asatt")); isAttachment {
options = append(options, aliyunoss.ResponseContentDisposition("attachment")) 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: 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 { if err != nil {
return nil, 0, err return nil, 0, err
@@ -245,16 +284,17 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
return "", 0, err return "", 0, err
} }
fname := fdef.Id fname := media.FileURLName(fdef)
fname += media.ExtensionByMimeType(fdef.MimeType)
fdef.Location = key fdef.Location = key
fdef.ETag = etagFromLocation(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 != "" { if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag fdef.ETag = etag
} }
}
return oh.conf.ServeURL + fname, rc.count, nil 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.Location = key
fdef.ETag = etagFromLocation(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 != "" { if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
fdef.ETag = etag fdef.ETag = etag
} }
}
return key, rc.count, nil return key, rc.count, nil
} }
@@ -316,6 +358,36 @@ func (oh *ossHandler) getFileRecord(tenantID types.TenantID, fid types.Uid) (*ty
return fd, nil 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) { func tenantObjectKey(tenantID types.TenantID, fileID string, createdAt time.Time) (string, error) {
if !tenantID.IsValid() || fileID == "" { if !tenantID.IsValid() || fileID == "" {
return "", types.ErrMalformed 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 package oss
import ( import (
"net/http"
"testing" "testing"
"time" "time"
aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "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) { func TestLoadSysConfig(t *testing.T) {
original := store.ThirdPartyConfig original := store.ThirdPartyConfig
store.ThirdPartyConfig = fakeThirdPartyConfig{ store.ThirdPartyConfig = fakeThirdPartyConfig{
@@ -57,6 +69,10 @@ func TestLoadSysConfig(t *testing.T) {
"oss_access_key_id": "ak", "oss_access_key_id": "ak",
"oss_access_key_secret": "sk", "oss_access_key_secret": "sk",
"oss_presign_ttl": "300", "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 }() defer func() { store.ThirdPartyConfig = original }()
@@ -70,4 +86,10 @@ func TestLoadSysConfig(t *testing.T) {
if handler.conf.RegionID != "cn-shenzhen" || handler.conf.PresignTTL != 300 { if handler.conf.RegionID != "cn-shenzhen" || handler.conf.PresignTTL != 300 {
t.Fatalf("unexpected OSS region or ttl: %+v", handler.conf) 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 return "", 0, err
} }
fname := fdef.Id fname := media.FileURLName(fdef)
fname += media.ExtensionByMimeType(fdef.MimeType)
fdef.Location = key fdef.Location = key
if result.ETag != nil { if result.ETag != nil {
+4 -1
View File
@@ -23,7 +23,7 @@
"alias_tag": "alias", "alias_tag": "alias",
"media": { "media": {
"use_handler": "fs", "use_handler": "sys_config",
"max_size": 104857600, "max_size": 104857600,
"gc_period": 3600, "gc_period": 3600,
"gc_block_size": 100, "gc_block_size": 100,
@@ -32,6 +32,9 @@
"upload_dir": "uploads", "upload_dir": "uploads",
"cache_control": "max-age=86400", "cache_control": "max-age=86400",
"cors_origins": ["*"] "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 { if err == nil && rec.State != types.StateOK {
err = types.ErrPermissionDenied 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 { if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp) 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 // Record deviceId used in this session
if s.deviceID != "" { 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{ if err := store.Devices.Update(s.tenantID, rec.Uid, "", &types.DeviceDef{
TenantID: s.tenantID, TenantID: s.tenantID,
DeviceId: s.deviceID, DeviceId: s.deviceID,
@@ -1294,6 +1304,8 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
Lang: s.lang, Lang: s.lang,
}); err != nil { }); err != nil {
logs.Warn.Println("failed to update device record", err) 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() { if !s.uid.IsZero() {
@@ -1310,6 +1322,55 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
return reply 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 { func currentNodeID() string {
if globals.cluster != nil && globals.cluster.thisNodeName != "" { if globals.cluster != nil && globals.cluster.thisNodeName != "" {
return globals.cluster.thisNodeName return globals.cluster.thisNodeName
+76
View File
@@ -19,6 +19,48 @@ type testTenantStore struct {
err error 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) { func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
return s.tenant, s.err 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) { func TestDispatchHello(t *testing.T) {
oldTenants := store.Tenants oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{ store.Tenants = testTenantStore{tenant: &types.Tenant{
+9
View File
@@ -77,18 +77,27 @@ func statsInit(mux *http.ServeMux, path string) {
func statsRegisterDbStats() { func statsRegisterDbStats() {
if f := store.Store.DbStats(); f != nil { if f := store.Store.DbStats(); f != nil {
if expvar.Get("DbStats") != nil {
return
}
expvar.Publish("DbStats", expvar.Func(f)) expvar.Publish("DbStats", expvar.Func(f))
} }
} }
// Register integer variable. Don't check for initialization. // Register integer variable. Don't check for initialization.
func statsRegisterInt(name string) { func statsRegisterInt(name string) {
if expvar.Get(name) != nil {
return
}
expvar.Publish(name, new(expvar.Int)) expvar.Publish(name, new(expvar.Int))
} }
// Register histogram variable. `bounds` specifies histogram buckets/bins // Register histogram variable. `bounds` specifies histogram buckets/bins
// (see comment next to the `histogram` struct definition). // (see comment next to the `histogram` struct definition).
func statsRegisterHistogram(name string, bounds []float64) { func statsRegisterHistogram(name string, bounds []float64) {
if expvar.Get(name) != nil {
return
}
numBuckets := len(bounds) + 1 numBuckets := len(bounds) + 1
expvar.Publish(name, &histogram{ expvar.Publish(name, &histogram{
CountPerBucket: make([]int64, numBuckets), CountPerBucket: make([]int64, numBuckets),
+29
View File
@@ -71,6 +71,7 @@ type groupMessageRuntime interface {
CacheObject(tenantID types.TenantID, namespace, id string, value any) error CacheObject(tenantID types.TenantID, namespace, id string, value any) error
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
DeleteCachedObject(tenantID types.TenantID, namespace, id string) 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 AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
RemoveCachedSetMember(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) 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) 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 { func CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 { if groupMsgRuntime == nil || !tenantID.IsValid() || msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return nil 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() 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 { func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id) key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background() ctx := context.Background()
@@ -11,6 +11,7 @@ import (
type testGroupMessageRuntime struct { type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord claims map[string]*groupMessageUniqueRecord
seqs map[string]int64 seqs map[string]int64
deletedPrefixes []string
} }
func newTestGroupMessageRuntime() *testGroupMessageRuntime { func newTestGroupMessageRuntime() *testGroupMessageRuntime {
@@ -116,6 +117,11 @@ func (r *testGroupMessageRuntime) DeleteCachedObject(types.TenantID, string, str
return nil 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 { func (r *testGroupMessageRuntime) AddCachedSetMember(types.TenantID, string, string, string) error {
return nil 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) 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 package mock_store
import ( import (
context "context"
json "encoding/json" json "encoding/json"
reflect "reflect" reflect "reflect"
time "time" 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)) 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. // GetDbVersion mocks base method.
func (m *MockPersistentStorageInterface) GetDbVersion() int { func (m *MockPersistentStorageInterface) GetDbVersion() int {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+190 -25
View File
@@ -2,6 +2,7 @@
package store package store
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -120,6 +121,7 @@ type PersistentStorageInterface interface {
GetUid() types.Uid GetUid() types.Uid
GetUidString() string GetUidString() string
DbStats() func() any DbStats() func() any
HealthCheck(context.Context) error
GetAuthNames() []string GetAuthNames() []string
GetAuthHandler(name string) auth.AuthHandler GetAuthHandler(name string) auth.AuthHandler
GetLogicalAuthHandler(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 { if tenant, ok := tenantByCodeCache.get(code); ok {
return cloneTenant(tenant), nil 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 adminInternalConfigProvider != nil {
if tenant, err := adminInternalConfigProvider.GetTenantByCode(code); err == nil && tenant != nil { if tenant, err := adminInternalConfigProvider.GetTenantByCode(code); err == nil && tenant != nil {
tenantByCodeCache.set(code, cloneTenant(tenant)) 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) logs.Warn.Printf("admin internal tenant by code fallback failed: code=%s err=%v", code, err)
} }
} }
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok { if !ok {
return nil, types.ErrUnsupported return nil, types.ErrUnsupported
} }
tenant, err := tenantAdp.TenantGetByCode(code) return 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
} }
// GetByID resolves an internal tenant ID to its registry record. // 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 { if tenant, ok := tenantByIDCache.get(idKey); ok {
return cloneTenant(tenant), nil 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 adminInternalConfigProvider != nil {
if tenant, err := adminInternalConfigProvider.GetTenantByID(id); err == nil && tenant != nil { if tenant, err := adminInternalConfigProvider.GetTenantByID(id); err == nil && tenant != nil {
tenantByIDCache.set(idKey, cloneTenant(tenant)) 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) logs.Warn.Printf("admin internal tenant by id fallback failed: tenant=%d err=%v", id, err)
} }
} }
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok { if !ok {
return nil, types.ErrUnsupported return nil, types.ErrUnsupported
} }
tenant, err := tenantAdp.TenantGetByID(id) return tenantAdp.TenantGetByID(id)
if err == nil && tenant != nil {
tenantByIDCache.set(idKey, cloneTenant(tenant))
tenantByCodeCache.set(tenant.Code, cloneTenant(tenant))
}
return tenant, err
} }
func (tenantsMapper) ListActive(limit int) ([]types.Tenant, error) { func (tenantsMapper) ListActive(limit int) ([]types.Tenant, error) {
if limit <= 0 { if limit <= 0 {
limit = 1000 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 adminInternalConfigProvider != nil {
if tenants, err := adminInternalConfigProvider.ListActiveTenants(limit); err == nil { if tenants, err := adminInternalConfigProvider.ListActiveTenants(limit); err == nil {
for i := range tenants { 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) logs.Warn.Printf("admin internal active tenants fallback failed: err=%v", err)
} }
} }
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok { if !ok {
return nil, types.ErrUnsupported return nil, types.ErrUnsupported
} }
@@ -473,6 +495,19 @@ func (storeObj) GetAdapter() adapter.Adapter {
return adp 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. // GetAdapterName returns the name of the current adater.
func (storeObj) GetAdapterName() string { func (storeObj) GetAdapterName() string {
if adp != nil { if adp != nil {
@@ -1760,6 +1795,16 @@ func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (map[string]a
return nil, false 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) { func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() { if strings.TrimSpace(topic) == "" || uid.IsZero() {
return nil, types.ErrMalformed return nil, types.ErrMalformed
@@ -1775,6 +1820,7 @@ func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, u
} else { } else {
_ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid)) _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid))
} }
invalidateGroupMemberPages(tenantID, topic)
} }
return member, err return member, err
} }
@@ -1798,7 +1844,11 @@ func (groupBusinessMapper) AcceptInvitation(tenantID types.TenantID, invitationI
if err != nil { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return false, err 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) 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 { 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, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true))
invalidateGroupMemberPages(tenantID, topic)
return nil 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, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), target))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, false)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, false))
_ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, true)) _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, true))
invalidateGroupMemberPages(tenantID, topic)
return nil return nil
} }
@@ -1999,7 +2059,17 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
if err != nil { if err != nil {
return err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 {
return false return false
} }
if patch.Region != nil && len([]rune(strings.TrimSpace(*patch.Region))) > 128 {
return false
}
return true 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 // LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
// from being garbage collected. // from being garbage collected.
func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error { 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. // Convert attachment URLs to file IDs.
var fids []string var fids []string
for _, url := range attachments { 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()) fids = append(fids, fid.String())
} }
} }
if len(fids) > 0 { if len(fids) > 0 {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
userId := types.ZeroUid userId := types.ZeroUid
if topic != "" && types.GetTopicCat(topic) == types.TopicCatMe { if topic != "" && types.GetTopicCat(topic) == types.TopicCatMe {
userId = types.ParseUserId(topic) userId = types.ParseUserId(topic)
+5
View File
@@ -73,6 +73,10 @@ type GroupMember struct {
Role string `db:"role"` Role string `db:"role"`
MemberStatus string `db:"member_status"` MemberStatus string `db:"member_status"`
Nickname string `db:"nickname"` Nickname string `db:"nickname"`
ModeWant AccessMode `db:"modewant"`
ModeGiven AccessMode `db:"modegiven"`
Public any `db:"public"`
Trusted any `db:"trusted"`
Pinned bool `db:"pinned"` Pinned bool `db:"pinned"`
MuteNotifications bool `db:"mute_notifications"` MuteNotifications bool `db:"mute_notifications"`
JoinSource string `db:"join_source"` JoinSource string `db:"join_source"`
@@ -105,6 +109,7 @@ type GroupMemberListOptions struct {
Query string Query string
Cursor string Cursor string
Limit int Limit int
Mentionable bool
} }
type GroupInvitation struct { type GroupInvitation struct {
+3
View File
@@ -1479,6 +1479,9 @@ type FileDef struct {
User string User string
// Type of the file. // Type of the file.
MimeType string 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 of the file in bytes.
Size int64 Size int64
// Internal file location, i.e. path on disk or an S3 blob address. // 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", "alias_tag": "alias",
"media": { "media": {
"use_handler": "fs", "use_handler": "sys_config",
"max_size": 104857600, "max_size": 104857600,
"gc_period": 3600, "gc_period": 3600,
"gc_block_size": 100, "gc_block_size": 100,
@@ -32,6 +32,9 @@
"upload_dir": "uploads", "upload_dir": "uploads",
"cache_control": "max-age=86400", "cache_control": "max-age=86400",
"cors_origins": ["*"] "cors_origins": ["*"]
},
"oss": {
"use_sys_config": true
} }
} }
}, },
+84 -4
View File
@@ -12,6 +12,7 @@ import (
"context" "context"
"errors" "errors"
"sort" "sort"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -1215,6 +1216,18 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return 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 isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil
if isCall { 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) { func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) {
mentionAll, _ := head["mention_all"].(bool) mentionAll, _ := head["mention_all"].(bool)
rawMentions, hasMentions := head["mentions"] rawMentions, hasMentions := head["mentions"]
if !mentionAll && !hasMentions { if !mentionAll && !hasMentions {
return head, nil return head, nil
} }
pud := t.perUser[asUid] if mentionAll {
access := pud.modeGiven & pud.modeWant if sub, err := store.Subs.Get(t.tenantID, t.name, asUid, false); err != nil {
if mentionAll && !access.IsAdmin() { return nil, err
} else if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsAdmin() {
return nil, types.ErrPermissionDenied return nil, types.ErrPermissionDenied
} }
}
validMembers := make(map[string]struct{}, len(t.perUser)) validMembers := make(map[string]struct{}, len(t.perUser))
for uid, pud := range t.perUser { for uid, pud := range t.perUser {
mode := pud.modeGiven & pud.modeWant 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.Public = t.public
desc.Trusted = t.trusted desc.Trusted = t.trusted
} else if full && t.cat == types.TopicCatP2P { } 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.Public = pud.public
desc.Trusted = pud.trusted desc.Trusted = pud.trusted
} }
} }
}
// Request may come from a subscriber (full == true) or a stranger. // Request may come from a subscriber (full == true) or a stranger.
// Give subscriber a fuller description than to a stranger/channel reader. // Give subscriber a fuller description than to a stranger/channel reader.
+97
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "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) { func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}
@@ -881,6 +942,9 @@ func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) {
pud.modeWant = types.ModeCPublic pud.modeWant = types.ModeCPublic
pud.modeGiven = types.ModeCPublic pud.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[0]] = pud 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() from := helper.uids[0].UserId()
msg := &ClientComMessage{ 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) { func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}
+6 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"container/heap" "container/heap"
crand "crypto/rand" crand "crypto/rand"
"errors"
"math/rand" "math/rand"
"strings" "strings"
"time" "time"
@@ -216,7 +217,11 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Create user record in the database. // Create user record in the database.
if _, err := store.Users.Create(s.tenantID, &user, private); err != nil { if _, err := store.Users.Create(s.tenantID, &user, private); err != nil {
logs.Warn.Println("create user: failed to create user", err, "sid=", s.sid) 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 return
} }
+45
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "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) { func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) {
oldUsers := store.Users oldUsers := store.Users
oldVerifications := store.Verifications oldVerifications := store.Verifications