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