diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 866920e..a677541 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -395,7 +395,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 - 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会根据已校验的 `cred` 建立手机号凭证;客户端主动设置 `tags:["tel:..."]` 会被 Tinode 权限策略拒绝并返回 `403 params.what=tags`。 +- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会把已校验手机号写入 `im_users.phone`,并保留验证凭证记录;用户标签已废弃,客户端设置 `tags` 会返回 `403 params.what=tags`。 - 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 ```json @@ -1679,7 +1679,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 +- 查询 `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`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index 5621bcc..50db136 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -7,6 +7,7 @@ -- 2. Tenant-scoped business tables live in each tenant database and do not include tenant_id. -- 3. This file is for review and manual execution. Existing legacy columns/indexes should be checked before ALTER/DROP. -- 4. Table definitions are aligned with server/db/mysql/adapter.go plus im_verification_codes used by chapter 1 verification APIs. +-- 5. Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator. SET NAMES utf8mb4; @@ -54,12 +55,14 @@ CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config ( -- ============================================================ CREATE TABLE IF NOT EXISTS tinode.im_user_security ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, auth_version INT NOT NULL DEFAULT 1, password_updated_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (user_id) + PRIMARY KEY (id), + UNIQUE INDEX uk_im_user_security_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS tinode.im_verification_codes ( @@ -125,6 +128,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_action_tokens ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, purpose VARCHAR(64) NOT NULL, password_hash VARBINARY(255) NOT NULL, @@ -136,7 +140,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings ( locked_until DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (user_id, purpose) + PRIMARY KEY (id), + UNIQUE INDEX uk_im_youth_mode_settings_user_purpose (user_id, purpose) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- ============================================================ @@ -234,6 +239,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_friend_requests ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS tinode.im_friendships ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, peer_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL, @@ -241,16 +247,19 @@ CREATE TABLE IF NOT EXISTS tinode.im_friendships ( topic CHAR(25) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (user_id, peer_id), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_friendships_pair (user_id, peer_id), INDEX idx_im_friendships_peer (peer_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS tinode.im_user_blocks ( + id BIGINT NOT NULL, blocker_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (blocker_id, blocked_id), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_user_blocks_pair (blocker_id, blocked_id), INDEX idx_im_user_blocks_blocked (blocked_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; @@ -553,6 +562,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions ( -- ============================================================ CREATE TABLE IF NOT EXISTS tinode.im_user_profiles ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '', @@ -560,14 +570,26 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_profiles ( signature VARCHAR(256) NOT NULL DEFAULT '', gender VARCHAR(16) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '', version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (user_id) + PRIMARY KEY (id), + UNIQUE INDEX uk_im_user_profiles_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +-- Existing database migration (the application performs the same migration +-- idempotently at startup): +-- 1. Add im_users.phone VARCHAR(32) NULL and a UNIQUE index. +-- 2. Add im_user_profiles.email VARCHAR(128) NOT NULL DEFAULT ''. +-- 3. Backfill phone/email from validated im_credentials first, then use +-- tel:/email: rows in im_usertags as a legacy fallback. +-- 4. Drop im_usertags and im_users.tags only after the backfill is verified. +-- im_topics.tags and im_topictags are topic discovery data and must be retained. + CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, add_me_methods JSON, profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', @@ -576,7 +598,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (user_id) + PRIMARY KEY (id), + UNIQUE INDEX uk_im_user_privacy_settings_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS tinode.im_app_releases ( @@ -599,6 +622,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_app_releases ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens ( + id BIGINT NOT NULL, device_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, provider VARCHAR(32) NOT NULL, @@ -607,7 +631,8 @@ CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens ( platform VARCHAR(32) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (device_id), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_device_push_tokens_device (device_id), UNIQUE INDEX uk_im_device_push_tokens_provider_token (provider, token), INDEX idx_im_device_push_tokens_user (user_id, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/docs/IM-未实现后续增强项.md b/docs/IM-未实现后续增强项.md index 190835e..381490d 100644 --- a/docs/IM-未实现后续增强项.md +++ b/docs/IM-未实现后续增强项.md @@ -127,6 +127,8 @@ 9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。 10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。 11. 好友个人主页已执行 `profile_visibility`。 +12. 用户搜索已改用昵称、公开账号、登录账号和 `im_users.phone` 等结构化字段,不再依赖用户标签。 +13. 注册手机号以 `im_users.phone` 为真源,邮箱作为 `im_user_profiles.email` 资料字段;旧 `im_users.tags/im_usertags` 已迁移删除,Topic 标签保留。 ### 5.2 后续增强项 diff --git a/docs/IM-第一至七章数据库增量审核.sql b/docs/IM-第一至七章数据库增量审核.sql index f0f97b5..c474445 100644 --- a/docs/IM-第一至七章数据库增量审核.sql +++ b/docs/IM-第一至七章数据库增量审核.sql @@ -3,6 +3,7 @@ -- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。 -- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。 -- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。 +-- 本文件中的非自增 BIGINT 主键由服务端雪花算法生成。 SET NAMES utf8mb4; @@ -79,13 +80,15 @@ CREATE TABLE IF NOT EXISTS im_verification_codes( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS im_user_security( + id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, auth_version INT NOT NULL DEFAULT 1, password_updated_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id,user_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_security_tenant_user(tenant_id,user_id), CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; @@ -132,6 +135,7 @@ CREATE TABLE IF NOT EXISTS im_action_tokens( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS im_youth_mode_settings( + id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, purpose VARCHAR(64) NOT NULL, @@ -140,7 +144,8 @@ CREATE TABLE IF NOT EXISTS im_youth_mode_settings( locked_until DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id, user_id, purpose), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_youth_settings_tenant_user_purpose(tenant_id, user_id, purpose), CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; @@ -192,6 +197,7 @@ CREATE UNIQUE INDEX uk_im_friend_requests_idempotency ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, ''))); CREATE TABLE IF NOT EXISTS im_friendships( + id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, peer_id BIGINT NOT NULL, @@ -200,18 +206,21 @@ CREATE TABLE IF NOT EXISTS im_friendships( topic CHAR(25) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id, user_id, peer_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_friendships_tenant_pair(tenant_id, user_id, peer_id), INDEX idx_im_friendships_peer(tenant_id, peer_id, status), CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS im_user_blocks( + id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, blocker_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id, blocker_id, blocked_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_blocks_tenant_pair(tenant_id, blocker_id, blocked_id), INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; @@ -312,5 +321,6 @@ CREATE TABLE IF NOT EXISTS im_message_revisions( -- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic; -- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid); --- im_usertags/im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id; +-- im_usertags 已废弃;im_users.phone 是手机号真源,邮箱存放于 im_user_profiles.email。 +-- im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id; -- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。 diff --git a/docs/IM-系统设计文档.md b/docs/IM-系统设计文档.md index cb4b478..a67057c 100644 --- a/docs/IM-系统设计文档.md +++ b/docs/IM-系统设计文档.md @@ -205,7 +205,7 @@ HTTP API 通用规则: | 模块 | 目标表 | 主要职责 | | --- | --- | --- | | 好友关系 | `im_friendships`、`im_friend_requests`、`im_user_blocks` | 好友申请、同意、拒绝、删除、拉黑 | -| 用户资料 | `im_user_profiles`、`im_user_privacy_settings` | 昵称、生日、签名、可见性和添加方式 | +| 用户资料 | `im_users`、`im_user_profiles`、`im_user_privacy_settings` | 注册手机号、昵称、邮箱、生日、签名、可见性和添加方式 | | 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_announcements`、`im_group_mutes`、`im_group_admin_logs` | 入群审批、公告、禁言、管理员、转让群主 | | 群文件 | `im_group_files` | 群文件目录、搜索、删除权限 | | 收藏 | `im_favorites` | 收藏去重、分类、来源、搜索和分享 | @@ -234,6 +234,10 @@ HTTP API 通用规则: | 群成员 | `im_group_members` | 群成员业务状态、角色、入群来源和操作者 | | 消息版本 | `im_message_revisions` | 编辑前内容、版本、操作者和变更原因 | +MySQL 表主键约定:所有 `im_*` 基础表必须具有独立的 `id` 主键。`im_user_security`、`im_kvmeta`、用户资料/隐私、设备 Push Token、好友/黑名单、青少年模式以及群设置/公告已读/禁言等原先使用自然键或复合主键的表,统一改为服务端雪花算法生成的 `BIGINT id` 主键;原业务键保留唯一索引,查询和幂等语义不变。服务启动时会对当前租户库执行幂等迁移,为存量行补齐雪花 ID 后再替换主键。迁移期间不得同时运行不写入 `id` 的旧版本服务。 + +用户身份字段约定:`im_users.phone` 是注册、密码找回和敏感操作验证使用的手机号真源,新注册必须提供已验证手机号;历史无手机号账号迁移后允许暂时为 `NULL`。`im_user_profiles.email` 仅是未验证的资料字段,不参与登录和密码找回。旧 `im_users.tags` 与 `im_usertags` 已废弃,启动迁移会先从已验证凭证和旧标签回填手机号/邮箱,再删除两者。群组发现仍使用 `im_topics.tags` 与 `im_topictags`,不受本次调整影响。 + ### 7.3 企业码与登录流程 1. 未认证客户端先通过 `hi.tenant` 提交企业码;服务端只按 `im_tenant.code` 查找启用租户并把 `TenantID` 固定到 Session。 @@ -506,6 +510,8 @@ end_reason, created_at, updated_at | --- | --- | | 头像、昵称、签名 | User Public + `im_user_profiles` | | 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 | +| 注册手机号 | `im_users.phone`,仅账号安全流程内部使用 | +| 邮箱 | `im_user_profiles.email`,仅作为个人资料,不作为认证凭证 | | 实名姓名、证件号 | 实名表加密存储,不进入 Public | | 群昵称 | `im_group_members`,按需投影到 Subscription Private | diff --git a/docs/IM-群聊业务表设计.sql b/docs/IM-群聊业务表设计.sql index 5041687..dc8532c 100644 --- a/docs/IM-群聊业务表设计.sql +++ b/docs/IM-群聊业务表设计.sql @@ -3,10 +3,12 @@ -- Database: the currently selected tenant database. -- Important: one tenant owns one database, so these tables intentionally do not contain tenant_id. -- This SQL is for review first. Do not execute directly in production before confirming indexes and migration order. +-- Non-auto-increment BIGINT primary keys are generated by the server Snowflake generator. -- 1. Group profile and governance truth. -- Topic/Subscription remains the communication projection; this table is the product business truth. CREATE TABLE IF NOT EXISTS im_group_settings ( + id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL DEFAULT '', @@ -27,7 +29,8 @@ CREATE TABLE IF NOT EXISTS im_group_settings ( dissolved_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (topic), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_group_settings_topic (topic), INDEX idx_im_group_settings_owner (owner_user_id, group_status), INDEX idx_im_group_settings_status_updated (group_status, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; @@ -135,19 +138,22 @@ CREATE TABLE IF NOT EXISTS im_group_announcements ( -- 6. Announcement read receipts. CREATE TABLE IF NOT EXISTS im_group_announcement_reads ( + id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (announcement_id, user_id), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_group_announcement_reads_announcement_user (announcement_id, user_id), INDEX idx_im_group_announcement_reads_topic_user (topic, user_id, read_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 7. Current member mute state. -- Historical changes are stored in im_group_admin_logs. CREATE TABLE IF NOT EXISTS im_group_mutes ( + id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL, @@ -160,7 +166,8 @@ CREATE TABLE IF NOT EXISTS im_group_mutes ( version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (topic, user_id), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_group_mutes_topic_user (topic, user_id), INDEX idx_im_group_mutes_topic_status (topic, status, expires_at), INDEX idx_im_group_mutes_user_status (user_id, status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/docs/IM-需求实现情况审核报告.md b/docs/IM-需求实现情况审核报告.md index a6383bf..68e9074 100644 --- a/docs/IM-需求实现情况审核报告.md +++ b/docs/IM-需求实现情况审核报告.md @@ -5,6 +5,7 @@ > 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md` > 多租户依据:`IM-独立库多租户改造方案.md` > 明确排除:已废弃的 `IM-服务端多租户改造方案.md` +> 数据库更新:2026-08-18 已完成无独立 `id` 表的雪花主键改造和本地库迁移。 ## 1. 审核说明 @@ -353,10 +354,13 @@ - 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。 - 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。 - `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。 +- MySQL 业务表已统一具备独立 `id` 主键;原先使用自然键或复合主键的 11 张表已改用服务端雪花 `BIGINT id`,原业务键保留唯一索引。 +- 应用启动会在当前租户库幂等补列、回填存量雪花 ID 并切换主键;迁移使用数据库级 advisory lock,独立租户库与残留 `tenant_id` 旧结构均可保留原业务唯一性。 +- 用户身份字段已结构化:手机号迁入 `im_users.phone`,邮箱迁入 `im_user_profiles.email`;用户 `tags/im_usertags` 在回填后删除,Topic 标签体系继续保留。 ### 需要修正 -1. 建立唯一 schema 版本和迁移入口,禁止“应用启动建部分表 + 手工 SQL 建另一部分表”长期并存。 +1. 继续把其他历史建表/升级逻辑收敛到唯一 schema 版本和迁移入口;本次雪花主键改造已具备统一自动迁移入口。 2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。 3. 新租户建库和存量租户升级必须执行同一套 migration。 4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。 diff --git a/scripts/apply_hotgo_tenant_db.go b/scripts/apply_hotgo_tenant_db.go index 9477807..565218a 100644 --- a/scripts/apply_hotgo_tenant_db.go +++ b/scripts/apply_hotgo_tenant_db.go @@ -132,6 +132,7 @@ func main() { INDEX idx_im_friend_requests_inbox(target_id, status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_friendships( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, peer_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL, @@ -139,15 +140,18 @@ func main() { topic CHAR(25) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id, peer_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id), INDEX idx_im_friendships_peer(peer_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_user_blocks( + id BIGINT NOT NULL, blocker_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(blocker_id, blocked_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id), INDEX idx_im_user_blocks_blocked(blocked_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_favorites( @@ -356,6 +360,7 @@ func main() { INDEX idx_im_mailbox_submissions_status(status, id DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_user_profiles( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '', @@ -363,13 +368,16 @@ func main() { signature VARCHAR(256) NOT NULL DEFAULT '', gender VARCHAR(16) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '', version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_profiles_user(user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, add_me_methods JSON, profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', @@ -378,7 +386,8 @@ func main() { version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_privacy_settings_user(user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_app_releases( id BIGINT NOT NULL AUTO_INCREMENT, @@ -399,6 +408,7 @@ func main() { UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens( + id BIGINT NOT NULL, device_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, provider VARCHAR(32) NOT NULL, @@ -407,11 +417,13 @@ func main() { platform VARCHAR(32) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(device_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_device_push_tokens_device(device_id), UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), INDEX idx_im_device_push_tokens_user(user_id, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, purpose VARCHAR(64) NOT NULL, password_hash VARBINARY(255) NOT NULL, @@ -423,7 +435,8 @@ func main() { locked_until DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id, purpose) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges( id BIGINT NOT NULL AUTO_INCREMENT, @@ -524,6 +537,8 @@ func main() { "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN policy_version INT NOT NULL DEFAULT 1 AFTER enabled", "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN restrictions JSON AFTER policy_version", "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions", + "ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent", + "ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region", } { if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") { fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) diff --git a/server/auth/basic/auth_basic.go b/server/auth/basic/auth_basic.go index 4a7fe29..87946c3 100644 --- a/server/auth/basic/auth_basic.go +++ b/server/auth/basic/auth_basic.go @@ -203,18 +203,17 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret return nil, err } - // Remove old tag from the list of tags - oldTag := a.name + ":" + login - for i, tag := range rec.Tags { - if tag == oldTag { - rec.Tags[i] = rec.Tags[len(rec.Tags)-1] - rec.Tags = rec.Tags[:len(rec.Tags)-1] - - break + if a.addToTags { + oldTag := a.name + ":" + login + for i, tag := range rec.Tags { + if tag == oldTag { + rec.Tags[i] = rec.Tags[len(rec.Tags)-1] + rec.Tags = rec.Tags[:len(rec.Tags)-1] + break + } } + rec.Tags = append(rec.Tags, a.name+":"+uname) } - // Add new tag - rec.Tags = append(rec.Tags, a.name+":"+uname) return rec, nil } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index e301968..c60ae97 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -246,6 +246,12 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { if err = ensureIMHTTPInfrastructureTables(a.db); err != nil { return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error()) } + if err = ensureSnowflakePrimaryKeys(a.db); err != nil { + return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) + } + if err = ensureUserIdentitySchema(a.db); err != nil { + return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error()) + } } } return err @@ -440,6 +446,14 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines db.Close() return nil, nil, err } + if err = ensureSnowflakePrimaryKeys(db); err != nil { + db.Close() + return nil, nil, err + } + if err = ensureUserIdentitySchema(db); err != nil { + db.Close() + return nil, nil, err + } tenantAdp := &adapter{ db: db, dsn: mysqlDefaultString(dsn, a.dsn), @@ -589,7 +603,13 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil { return err } - if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(registryDB)); err != nil { + if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { + return err + } + if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant_config"); err != nil { + return err + } + if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant"); err != nil { return err } } @@ -616,10 +636,11 @@ func (a *adapter) CreateDb(reset bool) error { access JSON, lastseen DATETIME, useragent VARCHAR(255) DEFAULT '', + phone VARCHAR(32), public JSON, trusted JSON, - tags JSON, PRIMARY KEY(id), + UNIQUE INDEX users_phone(phone), INDEX users_state_stateat(state, stateat), INDEX users_lastseen_updatedat(lastseen, updatedat) )`); err != nil { @@ -628,12 +649,14 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_user_security( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, auth_version INT NOT NULL DEFAULT 1, password_updated_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_security_user(user_id) )`); err != nil { return err } @@ -645,20 +668,6 @@ func (a *adapter) CreateDb(reset bool) error { return err } - // Indexed user tags. - if _, err = tx.Exec( - `CREATE TABLE im_usertags( - id INT NOT NULL AUTO_INCREMENT, - userid BIGINT NOT NULL, - tag VARCHAR(96) NOT NULL, - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES im_users(id), - INDEX usertags_tag(tag), - UNIQUE INDEX usertags_userid_tag(userid, tag) - )`); err != nil { - return err - } - // Indexed devices. Normalized into a separate table. if _, err = tx.Exec( `CREATE TABLE im_devices( @@ -919,15 +928,21 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_kvmeta(` + + "id BIGINT NOT NULL," + "`key` VARCHAR(64) NOT NULL," + "createdat DATETIME(3)," + "`value` TEXT," + - "PRIMARY KEY(`key`)," + + "PRIMARY KEY(id)," + + "UNIQUE INDEX uk_im_kvmeta_key(`key`)," + "INDEX kvmeta_createdat_key(createdat, `key`)" + `)`); err != nil { return err } - if _, err = tx.Exec("INSERT INTO im_kvmeta(`key`, `value`) VALUES('version',?)", adpVersion); err != nil { + versionID, err := nextSnowflakeID() + if err != nil { + return err + } + if _, err = tx.Exec("INSERT INTO im_kvmeta(id, `key`, `value`) VALUES(?,'version',?)", versionID, adpVersion); err != nil { return err } @@ -1225,6 +1240,7 @@ func createIMMailboxTables(tx *sql.Tx) error { func createIMUserProductTables(tx *sql.Tx) error { statements := []string{ `CREATE TABLE im_user_profiles( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', nickname VARCHAR(64) NOT NULL DEFAULT '', @@ -1232,13 +1248,16 @@ func createIMUserProductTables(tx *sql.Tx) error { signature VARCHAR(256) NOT NULL DEFAULT '', gender VARCHAR(16) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', public_account VARCHAR(64) NOT NULL DEFAULT '', version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_profiles_user(user_id) )`, `CREATE TABLE im_user_privacy_settings( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, add_me_methods JSON, profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', @@ -1247,7 +1266,8 @@ func createIMUserProductTables(tx *sql.Tx) error { version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_privacy_settings_user(user_id) )`, `CREATE TABLE im_app_releases( id BIGINT NOT NULL AUTO_INCREMENT, @@ -1268,6 +1288,7 @@ func createIMUserProductTables(tx *sql.Tx) error { UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel) )`, `CREATE TABLE im_device_push_tokens( + id BIGINT NOT NULL, device_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, provider VARCHAR(32) NOT NULL, @@ -1276,7 +1297,8 @@ func createIMUserProductTables(tx *sql.Tx) error { platform VARCHAR(32) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(device_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_device_push_tokens_device(device_id), UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), INDEX idx_im_device_push_tokens_user(user_id, updated_at) )`, @@ -1443,6 +1465,7 @@ func createIMRelationTables(exec sqlExecutor) error { INDEX idx_im_friend_requests_inbox(target_id, status, created_at) )`, `CREATE TABLE im_friendships( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, peer_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL, @@ -1450,15 +1473,18 @@ func createIMRelationTables(exec sqlExecutor) error { topic CHAR(25) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id, peer_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id), INDEX idx_im_friendships_peer(peer_id, status) )`, `CREATE TABLE im_user_blocks( + id BIGINT NOT NULL, blocker_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(blocker_id, blocked_id), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id), INDEX idx_im_user_blocks_blocked(blocked_id) )`, `CREATE TABLE im_favorites( @@ -1536,6 +1562,7 @@ func createIMSecurityTables(tx *sql.Tx) error { } _, err := tx.Exec( `CREATE TABLE im_youth_mode_settings( + id BIGINT NOT NULL, user_id BIGINT NOT NULL, purpose VARCHAR(64) NOT NULL, password_hash VARBINARY(255) NOT NULL, @@ -1547,7 +1574,8 @@ func createIMSecurityTables(tx *sql.Tx) error { locked_until DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(user_id, purpose) + PRIMARY KEY(id), + UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose) )`) return err } @@ -1650,27 +1678,31 @@ func (a *adapter) UserCreate(user *t.User) error { }() decoded_uid := store.DecodeUid(user.Uid()) - if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)", + securityID, err := nextSnowflakeID() + if err != nil { + return err + } + var phone any + if strings.TrimSpace(user.Phone) != "" { + phone = strings.TrimSpace(user.Phone) + } + if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)", decoded_uid, user.CreatedAt, user.UpdatedAt, user.State, user.Access, + phone, common.ToJSON(user.Public), - common.ToJSON(user.Trusted), - user.Tags); err != nil { + common.ToJSON(user.Trusted)); err != nil { return err } - if _, err = tx.Exec("INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?)", - decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil { - return err - } - - // Save user's tags to a separate table to make user findable. - if err = addTags(tx, "im_usertags", "userid", decoded_uid, user.Tags, false); err != nil { + if _, err = tx.Exec("INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)", + securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil { return err } + user.Tags = nil return tx.Commit() } @@ -1895,7 +1927,8 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) { defer cancel() } var user t.User - err := a.db.GetContext(ctx, &user, "SELECT * FROM im_users WHERE id=? AND state!=?", store.DecodeUid(uid), t.StateDeleted) + err := a.db.GetContext(ctx, &user, `SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent, + COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted) if err == nil { user.SetUid(uid) user.Public = common.FromJSON(user.Public) @@ -1925,7 +1958,8 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { if cancel != nil { defer cancel() } - q, uids, _ := sqlx.In("SELECT * FROM im_users WHERE id IN (?) AND state!=?", uids, t.StateDeleted) + q, uids, _ := sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent, + COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted) rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...) if err != nil { return nil, err @@ -2048,10 +2082,6 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { return err } - if _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid); err != nil { - return err - } - if _, err = tx.Exec("DELETE FROM im_users WHERE id=?", decoded_uid); err != nil { return err } @@ -2150,7 +2180,11 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error { } }() + update = withoutUserTags(update) cols, args := common.UpdateByMap(update) + if len(cols) == 0 { + return tx.Commit() + } decoded_uid := store.DecodeUid(uid) args = append(args, decoded_uid) _, err = tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) @@ -2166,76 +2200,12 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error { } } - // Tags are also stored in a separate table - if tags := common.ExtractTags(update); tags != nil { - // First delete all user tags - _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid) - if err != nil { - return err - } - // Now insert new tags - err = addTags(tx, "im_usertags", "userid", decoded_uid, tags, false) - if err != nil { - return err - } - } - return tx.Commit() } -// UserUpdateTags adds, removes, or resets user's tags. +// UserUpdateTags is retained for adapter compatibility; user tags are disabled. func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) { - ctx, cancel := a.getContextForTx() - if cancel != nil { - defer cancel() - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - - defer func() { - if err != nil { - tx.Rollback() - } - }() - - decoded_uid := store.DecodeUid(uid) - - if reset != nil { - // Delete all tags first if resetting. - _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid) - if err != nil { - return nil, err - } - add = reset - remove = nil - } - - // Now insert new tags. Ignore duplicates if resetting. - err = addTags(tx, "im_usertags", "userid", decoded_uid, add, reset == nil) - if err != nil { - return nil, err - } - - // Delete tags. - err = removeTags(tx, "im_usertags", "userid", decoded_uid, remove) - if err != nil { - return nil, err - } - - var allTags []string - err = tx.Select(&allTags, "SELECT tag FROM im_usertags WHERE userid=?", decoded_uid) - if err != nil { - return nil, err - } - - _, err = tx.Exec("UPDATE im_users SET tags=? WHERE id=?", t.StringSlice(allTags), decoded_uid) - if err != nil { - return nil, err - } - - return allTags, tx.Commit() + return nil, nil } // UserGetByCred returns user ID for the given validated credential. @@ -2245,7 +2215,12 @@ func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) { defer cancel() } var decoded_uid int64 - err := a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value) + var err error + if method == "tel" { + err = a.db.GetContext(ctx, &decoded_uid, "SELECT id FROM im_users WHERE phone=? AND state!=?", value, t.StateDeleted) + } else { + err = a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value) + } if err == nil { return store.EncodeUid(decoded_uid), nil } @@ -2266,6 +2241,22 @@ func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string) if tenantAdp != a { return tenantAdp.UserGetByCred(method, value) } + if !a.tenantScopedDB && method == "tel" { + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var decodedUID int64 + err = a.db.GetContext(ctx, &decodedUID, + "SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", tenantID, value, t.StateDeleted) + if err == sql.ErrNoRows { + return t.ZeroUid, nil + } + if err != nil { + return t.ZeroUid, err + } + return store.EncodeUid(decodedUID), nil + } return a.UserGetByCred(method, value) } @@ -3329,17 +3320,21 @@ func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error { return tx.Commit() } -// Find returns a list of users or group topics who match given tags, such as "email:jdoe@example.com" or "tel:+18003287448". +// Find returns group topics which match the requested discovery tags. func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) { + index := make(map[string]struct{}) + allReq := t.FlattenDoubleSlice(req) + allTags := append(append([]string{}, allReq...), opt...) + if len(allTags) == 0 { + return nil, nil + } var args []any stateConstraint := "" if activeOnly { args = append(args, t.StateOK) - stateConstraint = "u.state=? AND " + stateConstraint = "t.state=? AND " } - index := make(map[string]struct{}) - allReq := t.FlattenDoubleSlice(req) - for _, tag := range append(allReq, opt...) { + for _, tag := range allTags { args = append(args, tag) index[tag] = struct{}{} } @@ -3352,29 +3347,9 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, matcher = "COUNT(*)" } - query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " + - "FROM im_users AS u JOIN im_usertags AS tg ON tg.userid=u.id " + - "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " + - "GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags " - if len(allReq) > 0 { - q, a := common.DisjunctionSql(req, "tg.tag") - query += q - args = append(args, a...) - } - - query += "UNION ALL " - - if activeOnly { - args = append(args, t.StateOK) - stateConstraint = "t.state=? AND " - } - for _, tag := range append(allReq, opt...) { - args = append(args, tag) - } - - query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " + + query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " + "FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " + - "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allReq)+len(opt)-1) + ") " + + "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " + "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags " if len(allReq) > 0 { q, a := common.DisjunctionSql(req, "tg.tag") @@ -3443,29 +3418,17 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, return subs, err } -// FindOne returns the first topic or user which matches the given tag. +// FindOne returns the first topic which matches the given tag. func (a *adapter) FindOne(tag string) (string, error) { - var args []any - query := "SELECT t.name AS topic FROM im_topics AS t LEFT JOIN im_topictags AS tt ON t.name=tt.topic " + - "WHERE tt.tag=?" - args = append(args, tag) - - query += " UNION ALL " - - query += "SELECT u.id AS topic FROM im_users AS u LEFT JOIN im_usertags AS ut ON ut.userid=u.id " + - "WHERE ut.tag=?" - args = append(args, tag) - - // LIMIT is applied to all resultant rows. - query += " LIMIT 1" + "WHERE tt.tag=? LIMIT 1" ctx, cancel := a.getContext() if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, query, args...) + rows, err := a.db.QueryxContext(ctx, query, tag) if err != nil { return "", err } @@ -3998,6 +3961,11 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { } return true, err } + if cred.Done && cred.Method == "tel" { + if _, err = tx.Exec("UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')", cred.Value, userId); err != nil { + return true, err + } + } return true, tx.Commit() } @@ -4635,7 +4603,11 @@ func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) e action = "REPLACE" } - _, err := a.db.ExecContext(ctx, action+" INTO im_kvmeta(`key`,createdat,`value`) VALUES(?,?,?)", key, t.TimeNow(), value) + id, err := nextSnowflakeID() + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, action+" INTO im_kvmeta(id,`key`,createdat,`value`) VALUES(?,?,?,?)", id, key, t.TimeNow(), value) if isDupe(err) { return t.ErrDuplicate } diff --git a/server/db/mysql/group_schema.go b/server/db/mysql/group_schema.go index 84c3c5d..6b2c2a6 100644 --- a/server/db/mysql/group_schema.go +++ b/server/db/mysql/group_schema.go @@ -9,7 +9,7 @@ package mysql func ensureIMGroupBusinessTables(exec sqlExecutor) error { statements := []string{ `CREATE TABLE IF NOT EXISTS im_group_settings ( - topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL, + id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', description VARCHAR(512) NOT NULL DEFAULT '', group_status VARCHAR(16) NOT NULL DEFAULT 'active', join_approval TINYINT NOT NULL DEFAULT 0, mute_all TINYINT NOT NULL DEFAULT 0, @@ -19,7 +19,8 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error { version INT NOT NULL DEFAULT 1, created_by BIGINT NOT NULL, dissolved_by BIGINT, dissolved_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(topic), INDEX idx_im_group_settings_owner(owner_user_id, group_status), + PRIMARY KEY(id), UNIQUE INDEX uk_im_group_settings_topic(topic), + INDEX idx_im_group_settings_owner(owner_user_id, group_status), INDEX idx_im_group_settings_status_updated(group_status, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS im_group_members ( @@ -77,18 +78,20 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error { INDEX idx_im_group_announcements_topic_time(topic, published_at DESC, id DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS im_group_announcement_reads ( - announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, + id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(announcement_id, user_id), INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at) + PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcement_reads_announcement_user(announcement_id, user_id), + INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS im_group_mutes ( - topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL, + id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL, muted_at DATETIME(3) NOT NULL, expires_at DATETIME(3), reason VARCHAR(256) NOT NULL DEFAULT '', status VARCHAR(16) NOT NULL DEFAULT 'active', revoked_by BIGINT, revoked_at DATETIME(3), version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(topic, user_id), INDEX idx_im_group_mutes_topic_status(topic, status, expires_at), + PRIMARY KEY(id), UNIQUE INDEX uk_im_group_mutes_topic_user(topic, user_id), + INDEX idx_im_group_mutes_topic_status(topic, status, expires_at), INDEX idx_im_group_mutes_user_status(user_id, status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS im_group_qrcodes ( diff --git a/server/db/mysql/group_schema_test.go b/server/db/mysql/group_schema_test.go index 8ce50b7..407d419 100644 --- a/server/db/mysql/group_schema_test.go +++ b/server/db/mysql/group_schema_test.go @@ -30,4 +30,14 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) { t.Fatalf("group schema must use the selected tenant database: %s", statement) } } + joined := strings.ToLower(strings.Join(exec.statements, "\n")) + for _, expected := range []string{ + "primary key(id), unique index uk_im_group_settings_topic(topic)", + "primary key(id), unique index uk_im_group_announcement_reads_announcement_user(announcement_id, user_id)", + "primary key(id), unique index uk_im_group_mutes_topic_user(topic, user_id)", + } { + if !strings.Contains(joined, expected) { + t.Fatalf("group schema is missing snowflake primary-key definition %q", expected) + } + } } diff --git a/server/db/mysql/snowflake_primary_keys.go b/server/db/mysql/snowflake_primary_keys.go new file mode 100644 index 0000000..c1ea484 --- /dev/null +++ b/server/db/mysql/snowflake_primary_keys.go @@ -0,0 +1,178 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/tinode/chat/server/store" +) + +type snowflakePrimaryKeySpec struct { + table string + uniqueIndex string + businessKey []string +} + +var snowflakePrimaryKeySpecs = []snowflakePrimaryKeySpec{ + {table: "im_user_security", uniqueIndex: "uk_im_user_security_user", businessKey: []string{"user_id"}}, + {table: "im_kvmeta", uniqueIndex: "uk_im_kvmeta_key", businessKey: []string{"key"}}, + {table: "im_user_profiles", uniqueIndex: "uk_im_user_profiles_user", businessKey: []string{"user_id"}}, + {table: "im_user_privacy_settings", uniqueIndex: "uk_im_user_privacy_settings_user", businessKey: []string{"user_id"}}, + {table: "im_device_push_tokens", uniqueIndex: "uk_im_device_push_tokens_device", businessKey: []string{"device_id"}}, + {table: "im_friendships", uniqueIndex: "uk_im_friendships_pair", businessKey: []string{"user_id", "peer_id"}}, + {table: "im_user_blocks", uniqueIndex: "uk_im_user_blocks_pair", businessKey: []string{"blocker_id", "blocked_id"}}, + {table: "im_youth_mode_settings", uniqueIndex: "uk_im_youth_mode_settings_user_purpose", businessKey: []string{"user_id", "purpose"}}, + {table: "im_group_settings", uniqueIndex: "uk_im_group_settings_topic", businessKey: []string{"topic"}}, + {table: "im_group_announcement_reads", uniqueIndex: "uk_im_group_announcement_reads_announcement_user", businessKey: []string{"announcement_id", "user_id"}}, + {table: "im_group_mutes", uniqueIndex: "uk_im_group_mutes_topic_user", businessKey: []string{"topic", "user_id"}}, +} + +const snowflakeMigrationLock = "CONCAT('im_snowflake_pk:', LEFT(SHA2(DATABASE(), 256), 40))" + +func nextSnowflakeID() (int64, error) { + uid := store.Store.GetUid() + if uid.IsZero() { + return 0, errors.New("mysql: snowflake generator is not initialized") + } + id := store.DecodeUid(uid) + if id <= 0 { + return 0, errors.New("mysql: snowflake generator returned an invalid ID") + } + return id, nil +} + +// ensureSnowflakePrimaryKeys upgrades legacy business-key primary keys in the +// selected tenant database. The former primary-key columns remain unique. +func ensureSnowflakePrimaryKeys(db *sqlx.DB) error { + ctx := context.Background() + conn, err := db.Connx(ctx) + if err != nil { + return err + } + defer conn.Close() + + var locked sql.NullInt64 + if err = conn.QueryRowContext(ctx, + "SELECT GET_LOCK("+snowflakeMigrationLock+", 60)").Scan(&locked); err != nil { + return err + } + if !locked.Valid || locked.Int64 != 1 { + return errors.New("mysql: timed out waiting for snowflake primary-key migration lock") + } + defer conn.ExecContext(ctx, "SELECT RELEASE_LOCK("+snowflakeMigrationLock+")") + + for _, spec := range snowflakePrimaryKeySpecs { + if err = migrateSnowflakePrimaryKey(ctx, conn, spec); err != nil { + return fmt.Errorf("mysql: migrate %s primary key: %w", spec.table, err) + } + } + return nil +} + +func migrateSnowflakePrimaryKey(ctx context.Context, conn *sqlx.Conn, spec snowflakePrimaryKeySpec) error { + var exists int + if err := conn.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?)`, spec.table).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return nil + } + + if err := conn.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name='id')`, spec.table).Scan(&exists); err != nil { + return err + } + if exists == 0 { + if _, err := conn.ExecContext(ctx, "ALTER TABLE "+quoteIdent(spec.table)+" ADD COLUMN id BIGINT NULL FIRST"); err != nil { + return err + } + } + + for { + if err := conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM "+quoteIdent(spec.table)+" WHERE id IS NULL OR id=0 LIMIT 1)").Scan(&exists); err != nil { + return err + } + if exists == 0 { + break + } + id, err := nextSnowflakeID() + if err != nil { + return err + } + result, err := conn.ExecContext(ctx, "UPDATE "+quoteIdent(spec.table)+" SET id=? WHERE id IS NULL OR id=0 LIMIT 1", id) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + if err != nil { + return err + } + return errors.New("could not backfill exactly one row") + } + } + + if err := conn.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`, + spec.table, spec.uniqueIndex).Scan(&exists); err != nil { + return err + } + if exists == 0 { + businessKey := spec.businessKey + var hasTenantID int + if err := conn.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name='tenant_id')`, + spec.table).Scan(&hasTenantID); err != nil { + return err + } + if hasTenantID == 1 { + businessKey = append([]string{"tenant_id"}, businessKey...) + } + columns := make([]string, len(businessKey)) + for i, column := range businessKey { + columns[i] = quoteIdent(column) + } + statement := "CREATE UNIQUE INDEX " + quoteIdent(spec.uniqueIndex) + " ON " + + quoteIdent(spec.table) + "(" + strings.Join(columns, ",") + ")" + if _, err := conn.ExecContext(ctx, statement); err != nil { + return err + } + } + + var primaryColumns sql.NullString + if err := conn.QueryRowContext(ctx, `SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') + FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name=? AND index_name='PRIMARY'`, spec.table).Scan(&primaryColumns); err != nil { + return err + } + if primaryColumns.Valid && primaryColumns.String == "id" { + var dataType, nullable, extra string + if err := conn.QueryRowContext(ctx, `SELECT data_type,is_nullable,extra + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name=? AND column_name='id'`, spec.table). + Scan(&dataType, &nullable, &extra); err != nil { + return err + } + if strings.EqualFold(dataType, "bigint") && strings.EqualFold(nullable, "NO") && + !strings.Contains(strings.ToLower(extra), "auto_increment") { + return nil + } + _, err := conn.ExecContext(ctx, "ALTER TABLE "+quoteIdent(spec.table)+" MODIFY COLUMN id BIGINT NOT NULL") + return err + } + + statement := "ALTER TABLE " + quoteIdent(spec.table) + " MODIFY COLUMN id BIGINT NOT NULL" + if primaryColumns.Valid && primaryColumns.String != "" { + statement += ", DROP PRIMARY KEY" + } + statement += ", ADD PRIMARY KEY(id)" + _, err := conn.ExecContext(ctx, statement) + return err +} diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index f03ad5e..32e38b5 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -152,7 +152,8 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) { defer cancel() } var user t.User - err = a.db.GetContext(ctx, &user, "SELECT * FROM im_users WHERE tenant_id=? AND id=? AND state!=?", + err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent, + COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`, id, decodeUID(uid), t.StateDeleted) if err == sql.ErrNoRows { return nil, nil @@ -200,9 +201,11 @@ func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.Tena var q string var params []any if a.tenantScopedDB { - q, params, _ = sqlx.In("SELECT * FROM im_users WHERE id IN (?) AND state!=?", args, t.StateDeleted) + q, params, _ = sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent, + COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted) } else { - q, params, _ = sqlx.In("SELECT * FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?", id, args, t.StateDeleted) + q, params, _ = sqlx.In(`SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent, + COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?`, id, args, t.StateDeleted) } query := a.db.Rebind(q) var rows *sqlx.Rows @@ -308,9 +311,6 @@ func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error { if _, err = tx.Exec("DELETE FROM im_credentials WHERE userid=?", decoded); err != nil { return err } - if _, err = tx.Exec("DELETE FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil { - return err - } if _, err = tx.Exec("DELETE FROM im_users WHERE tenant_id=? AND id=?", id, decoded); err != nil { return err } @@ -389,7 +389,11 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a } }() decoded := decodeUID(uid) + update = withoutUserTags(update) cols, args := common.UpdateByMap(update) + if len(cols) == 0 { + return tx.Commit() + } args = append(args, id, decoded) res, err := tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...) if err != nil { @@ -404,14 +408,6 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a return err } } - if tags := common.ExtractTags(update); tags != nil { - if _, err = tx.Exec("DELETE FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil { - return err - } - if err = addTenantTags(tx, id, "im_usertags", "userid", decoded, tags, false); err != nil { - return err - } - } return tx.Commit() } @@ -426,43 +422,7 @@ func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, re if a.tenantScopedDB { return a.UserUpdateTags(uid, add, remove, reset) } - ctx, cancel := a.getContextForTx() - if cancel != nil { - defer cancel() - } - tx, err := a.db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - defer func() { - if err != nil { - tx.Rollback() - } - }() - decoded := decodeUID(uid) - if reset != nil { - if _, err = tx.Exec("DELETE FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil { - return nil, err - } - add, remove = reset, nil - } - if err = addTenantTags(tx, id, "im_usertags", "userid", decoded, add, reset == nil); err != nil { - return nil, err - } - if len(remove) > 0 { - q, params, _ := sqlx.In("DELETE FROM im_usertags WHERE tenant_id=? AND userid=? AND tag IN (?)", id, decoded, remove) - if _, err = tx.Exec(tx.Rebind(q), params...); err != nil { - return nil, err - } - } - var allTags []string - if err = tx.Select(&allTags, "SELECT tag FROM im_usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil { - return nil, err - } - if _, err = tx.Exec("UPDATE im_users SET tags=? WHERE tenant_id=? AND id=?", t.StringSlice(allTags), id, decoded); err != nil { - return nil, err - } - return allTags, tx.Commit() + return nil, nil } func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) { @@ -1520,7 +1480,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][] stateConstraint := "" if activeOnly { args = append(args, t.StateOK) - stateConstraint = "u.state=? AND " + stateConstraint = "t.state=? AND " } index := make(map[string]struct{}) for _, tag := range allTags { @@ -1531,27 +1491,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][] if promoPrefix != "" { matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)" } - query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " + - "FROM im_users AS u JOIN im_usertags AS tg ON tg.tenant_id=u.tenant_id AND tg.userid=u.id " + - "WHERE u.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " + - "GROUP BY u.id,u.createdat,u.updatedat,u.access,u.public,u.trusted,u.tags " - if len(allReq) > 0 { - q, addArgs := common.DisjunctionSql(req, "tg.tag") - query += q - args = append(args, addArgs...) - } - query += "UNION ALL " - args = append(args, id) - if activeOnly { - args = append(args, t.StateOK) - stateConstraint = "t.state=? AND " - } else { - stateConstraint = "" - } - for _, tag := range allTags { - args = append(args, tag) - } - query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " + + query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " + "FROM im_topics AS t JOIN im_topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " + "WHERE t.tenant_id=? AND " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " + "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags " @@ -1622,9 +1562,7 @@ func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) { } rows, err := a.db.QueryxContext(ctx, "SELECT tp.name AS topic FROM im_topics AS tp LEFT JOIN im_topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+ - "WHERE tp.tenant_id=? AND tt.tag=? UNION ALL "+ - "SELECT u.id AS topic FROM im_users AS u LEFT JOIN im_usertags AS ut ON u.tenant_id=ut.tenant_id AND ut.userid=u.id "+ - "WHERE u.tenant_id=? AND ut.tag=? LIMIT 1", id, tag, id, tag) + "WHERE tp.tenant_id=? AND tt.tag=? LIMIT 1", id, tag) if err != nil { return "", err } @@ -3816,7 +3754,11 @@ func (a *adapter) TenantUserProfileGet(id t.TenantID, uid t.Uid) (*t.UserProfile if cancel != nil { defer cancel() } - if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(user_id,public_account) VALUES(?,?)`, decodeUID(uid), uid.UserId()); err != nil { + profileID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil { return nil, err } return a.userProfileByUID(ctx, uid) @@ -3839,7 +3781,11 @@ func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.User if cancel != nil { defer cancel() } - if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(user_id,public_account) VALUES(?,?)`, decodeUID(uid), uid.UserId()); err != nil { + profileID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_profiles(id,user_id,public_account) VALUES(?,?,?)`, profileID, decodeUID(uid), uid.UserId()); err != nil { return nil, err } sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} @@ -3873,6 +3819,10 @@ func (a *adapter) TenantUserProfileUpdate(id t.TenantID, uid t.Uid, patch t.User sets = append(sets, "region=?") args = append(args, strings.TrimSpace(*patch.Region)) } + if patch.Email != nil { + sets = append(sets, "email=?") + args = append(args, strings.TrimSpace(*patch.Email)) + } args = append(args, decodeUID(uid), patch.Version) res, err := a.db.ExecContext(ctx, `UPDATE im_user_profiles SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...) if err != nil { @@ -3902,7 +3852,11 @@ func (a *adapter) TenantUserPrivacyGet(id t.TenantID, uid t.Uid) (*t.UserPrivacy if cancel != nil { defer cancel() } - if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(user_id,add_me_methods) VALUES(?,'{"account":true,"phone":true,"qr":true,"group":true}')`, decodeUID(uid)); err != nil { + privacyID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil { return nil, err } return a.userPrivacyByUID(ctx, uid) @@ -3925,7 +3879,11 @@ func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.User if cancel != nil { defer cancel() } - if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(user_id,add_me_methods) VALUES(?,'{"account":true,"phone":true,"qr":true,"group":true}')`, decodeUID(uid)); err != nil { + privacyID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + if _, err := a.db.ExecContext(ctx, `INSERT IGNORE INTO im_user_privacy_settings(id,user_id,add_me_methods) VALUES(?,?,'{"account":true,"phone":true,"qr":true,"group":true}')`, privacyID, decodeUID(uid)); err != nil { return nil, err } sets := []string{"version=version+1", "updated_at=CURRENT_TIMESTAMP(3)"} @@ -4048,12 +4006,16 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush token.Token = strings.TrimSpace(token.Token) token.AppVersion = strings.TrimSpace(token.AppVersion) token.Platform = strings.ToLower(strings.TrimSpace(token.Platform)) - _, err := a.db.ExecContext(ctx, - `INSERT INTO im_device_push_tokens(device_id,user_id,provider,token,app_version,platform,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?) + tokenID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + _, err = a.db.ExecContext(ctx, + `INSERT INTO im_device_push_tokens(id,device_id,user_id,provider,token,app_version,platform,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE device_id=VALUES(device_id),user_id=VALUES(user_id),provider=VALUES(provider),token=VALUES(token), app_version=VALUES(app_version),platform=VALUES(platform),updated_at=VALUES(updated_at)`, - token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now) + tokenID, token.DeviceID, decodeUID(token.UserID), token.Provider, token.Token, token.AppVersion, token.Platform, now, now) if err != nil { return nil, err } @@ -4515,12 +4477,14 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi Signature string Gender string Region string + Email string PublicAccount string `db:"public_account"` Version int CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil { + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,avatar_file_id,nickname,birth_date,signature,gender,region,email, + public_account,version,created_at,updated_at FROM im_user_profiles WHERE user_id=?`, decodeUID(uid)); err != nil { return nil, err } profile := &t.UserProfile{ @@ -4530,6 +4494,7 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi Signature: row.Signature, Gender: row.Gender, Region: row.Region, + Email: row.Email, PublicAccount: row.PublicAccount, Version: row.Version, CreatedAt: row.CreatedAt, @@ -4552,7 +4517,8 @@ func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPriva CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { + if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility, + read_receipts,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { return nil, err } settings := &t.UserPrivacySettings{ @@ -5615,11 +5581,15 @@ func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (in } decoded := decodeUID(uid) now := t.TimeNow() - _, err := a.db.ExecContext(ctx, - `INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) - VALUES(?,?,?,?) + securityID, err := nextSnowflakeID() + if err != nil { + return 0, err + } + _, err = a.db.ExecContext(ctx, + `INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE auth_version=auth_version`, - decoded, 1, now, now) + securityID, decoded, 1, now, now) if err != nil { return 0, err } @@ -5666,14 +5636,18 @@ func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, up } }() decoded := decodeUID(uid) + securityID, err := nextSnowflakeID() + if err != nil { + return 0, err + } if _, err = tx.ExecContext(ctx, - `INSERT INTO im_user_security(user_id,auth_version,password_updated_at,created_at,updated_at) - VALUES(?,?,?,?,?) + `INSERT INTO im_user_security(id,user_id,auth_version,password_updated_at,created_at,updated_at) + VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE auth_version=auth_version+1, password_updated_at=VALUES(password_updated_at), updated_at=VALUES(updated_at)`, - decoded, 2, updatedAt, updatedAt, updatedAt); err != nil { + securityID, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil { return 0, err } var version int @@ -5911,12 +5885,16 @@ func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.Indepen now := t.TimeNow() pass.CreatedAt = now pass.UpdatedAt = now - _, err := a.db.ExecContext(ctx, + settingsID, err := nextSnowflakeID() + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `INSERT INTO im_youth_mode_settings - (user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at) - VALUES(?,?,?,0,NULL,?,?) + (id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at) + VALUES(?,?,?,?,0,NULL,?,?) ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`, - decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt) + settingsID, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt) return err } @@ -6478,10 +6456,14 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act row.UpdatedAt = handledAt } for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} { + friendshipID, idErr := nextSnowflakeID() + if idErr != nil { + return nil, idErr + } if _, err = tx.ExecContext(ctx, - "INSERT INTO im_friendships(user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?) "+ + "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", - decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil { + friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil { return nil, err } } @@ -6642,10 +6624,14 @@ func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, to defer cancel() } for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} { + friendshipID, err := nextSnowflakeID() + if err != nil { + return err + } if _, err := a.db.ExecContext(ctx, - "INSERT INTO im_friendships(user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?) "+ + "INSERT INTO im_friendships(id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", - decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil { + friendshipID, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil { return err } } @@ -6680,13 +6666,13 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init } if q := strings.TrimSpace(queryText); q != "" { like := "%" + q + "%" - where = append(where, "(fs.remark LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR CAST(u.tags AS CHAR) LIKE ?)") - args = append(args, like, like, like) + where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ?)") + args = append(args, like, like, like, like) } if initial = strings.TrimSpace(initial); initial != "" { like := initial + "%" - where = append(where, "(fs.remark LIKE ? OR CAST(u.public AS CHAR) LIKE ?)") - args = append(args, like, like) + where = append(where, "(fs.remark LIKE ? OR p.nickname LIKE ? OR CAST(u.public AS CHAR) LIKE ?)") + args = append(args, like, like, like) } args = append(args, limit+1) ctx, cancel := a.getContext() @@ -6696,6 +6682,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted 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 WHERE `+strings.Join(where, " AND ")+` ORDER BY fs.peer_id ASC LIMIT ?`, args...) if err != nil { @@ -6776,7 +6763,8 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - err := a.db.GetContext(ctx, &row, "SELECT * FROM im_friendships WHERE user_id=? AND peer_id=?", + err := a.db.GetContext(ctx, &row, `SELECT user_id,peer_id,status,remark,topic,created_at,updated_at + FROM im_friendships WHERE user_id=? AND peer_id=?`, decodeUID(userID), decodeUID(peerID)) if err == sql.ErrNoRows { return nil, nil @@ -6871,7 +6859,8 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - err := a.db.GetContext(ctx, &row, "SELECT * FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?", + err := a.db.GetContext(ctx, &row, `SELECT blocker_id,blocked_id,created_at,updated_at + FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?`, decodeUID(blockerID), decodeUID(blockedID)) if err == sql.ErrNoRows { return nil, nil @@ -6919,7 +6908,7 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, `SELECT b.*,u.public,u.trusted + rows, err := a.db.QueryxContext(ctx, `SELECT b.blocker_id,b.blocked_id,b.created_at,b.updated_at,u.public,u.trusted FROM im_user_blocks AS b LEFT JOIN im_users AS u ON u.id=b.blocked_id WHERE `+strings.Join(where, " AND ")+` @@ -6986,10 +6975,14 @@ func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Ui if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, - "INSERT INTO im_user_blocks(blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?) "+ + blockID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + _, err = a.db.ExecContext(ctx, + "INSERT INTO im_user_blocks(id,blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)", - decodeUID(blockerID), decodeUID(blockedID), now, now) + blockID, decodeUID(blockerID), decodeUID(blockedID), now, now) if err != nil { return nil, err } @@ -7048,24 +7041,23 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc where = append(where, "u.id>?") args = append(args, parsed) } - joins := []string{"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id"} + joins := []string{ + "LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id", + "LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id", + } addMethod := "account" switch searchType { case "", "account": like := "%" + queryText + "%" - joins = append(joins, "LEFT JOIN im_usertags AS ut ON ut.userid=u.id") - where = append(where, "(ut.tag LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)") - args = append(args, like, like, userIDNumericArg(queryText)) + where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)") + args = append(args, like, like, like, userIDNumericArg(queryText)) case "exact_account": - joins = append(joins, "LEFT JOIN im_usertags AS ut ON ut.userid=u.id") - where = append(where, "(ut.tag=? OR u.id=?)") - args = append(args, queryText, userIDNumericArg(queryText)) + joins = append(joins, "LEFT JOIN im_auth AS account_auth ON account_auth.userid=u.id AND account_auth.scheme='basic'") + where = append(where, "(profile.public_account=? OR account_auth.uname=? OR u.id=?)") + args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText)) case "phone": addMethod = "phone" - where = append(where, `EXISTS ( - SELECT 1 FROM im_credentials AS c - WHERE c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true - )`) + where = append(where, "u.phone=?") args = append(args, queryText) default: return nil, "", t.ErrMalformed @@ -7080,7 +7072,8 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* FROM im_users AS u `+strings.Join(joins, " ")+` + rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent, + COALESCE(u.phone,'') AS phone,u.public,u.trusted FROM im_users AS u `+strings.Join(joins, " ")+` WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...) if err != nil { return nil, "", err diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 9560c25..3d10cd8 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -16,6 +16,13 @@ import ( t "github.com/tinode/chat/server/store/types" ) +const ( + groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all, + member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version, + created_by,dissolved_by,dissolved_at,created_at,updated_at` + groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at` +) + func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSettings) (*t.GroupSettings, error) { if tenantAdp, err := a.routeGroupTenant(id); err != nil { return nil, err @@ -45,11 +52,15 @@ func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSett if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, `INSERT INTO im_group_settings - (topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + settingsID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + _, err = a.db.ExecContext(ctx, `INSERT INTO im_group_settings + (id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),avatar_file_id=VALUES(avatar_file_id),description=VALUES(description),updated_at=VALUES(updated_at)`, - settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus, + settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt) if err != nil { @@ -626,8 +637,12 @@ func (a *adapter) TenantGroupAnnouncementRead(id t.TenantID, topic, announcement if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(announcement_id,topic,user_id,read_at) - VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, announcementID, topic, decodeUID(uid), readAt) + readID, err := nextSnowflakeID() + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(id,announcement_id,topic,user_id,read_at) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, readID, announcementID, topic, decodeUID(uid), readAt) return err } @@ -652,7 +667,7 @@ func (a *adapter) TenantGroupMuteList(id t.TenantID, topic, cursor string, limit if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_group_mutes WHERE "+strings.Join(where, " AND ")+" ORDER BY user_id LIMIT ?", args...) + rows, err := a.db.QueryxContext(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE "+strings.Join(where, " AND ")+" ORDER BY user_id LIMIT ?", args...) if err != nil { return nil, "", err } @@ -696,10 +711,14 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr return nil, err } defer tx.Rollback() - _, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version) - VALUES(?,?,?,?,?,?,?,1) + muteID, err := nextSnowflakeID() + if err != nil { + return nil, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version) + VALUES(?,?,?,?,?,?,?,?,1) ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`, - mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status) + muteID, mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status) if err != nil { return nil, err } @@ -711,7 +730,7 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr if err = tx.Commit(); err != nil { return nil, err } - return a.groupMuteByQuery(ctx, "SELECT * FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID)) + return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID)) } func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor t.Uid, now time.Time) error { @@ -1090,9 +1109,13 @@ func (a *adapter) TenantGroupEventMarkDelivered(id t.TenantID, eventID string, n } func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error { - _, err := tx.Exec(`INSERT INTO im_group_settings - (topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, + settingsID, err := nextSnowflakeID() + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO im_group_settings + (id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt) @@ -1143,7 +1166,7 @@ func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) { func (a *adapter) groupSettingsByTopic(ctx context.Context, topic string) (*t.GroupSettings, error) { var item t.GroupSettings - err := a.db.QueryRowxContext(ctx, "SELECT * FROM im_group_settings WHERE topic=?", topic).StructScan(&item) + err := a.db.QueryRowxContext(ctx, "SELECT "+groupSettingsSelectColumns+" FROM im_group_settings WHERE topic=?", topic).StructScan(&item) if err == sql.ErrNoRows { return nil, t.ErrNotFound } diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go index 58cd697..45346c2 100644 --- a/server/db/mysql/tests/mysql_test.go +++ b/server/db/mysql/tests/mysql_test.go @@ -117,6 +117,89 @@ func TestHTTPInfrastructureTablesCreated(t *testing.T) { } } +func TestUserIdentitySchema(t *testing.T) { + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='phone'`); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("im_users.phone is missing") + } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_user_profiles' AND column_name='email'`); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("im_user_profiles.email is missing") + } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='tags'`); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("legacy im_users.tags still exists") + } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='im_usertags'`); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("legacy im_usertags still exists") + } + if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name='im_topictags'`); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("topic tag table must be retained") + } +} + +func TestAllIMTablesUseIDPrimaryKey(t *testing.T) { + var tables []string + if err := db.Select(&tables, `SELECT t.table_name + FROM information_schema.tables AS t + WHERE t.table_schema=DATABASE() AND t.table_type='BASE TABLE' AND t.table_name LIKE 'im\_%' + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns AS c + WHERE c.table_schema=t.table_schema AND c.table_name=t.table_name AND c.column_name='id' + ) + ORDER BY t.table_name`); err != nil { + t.Fatal(err) + } + if len(tables) != 0 { + t.Fatalf("IM tables without id column: %v", tables) + } + + tables = nil + if err := db.Select(&tables, `SELECT s.table_name + FROM information_schema.statistics AS s + WHERE s.table_schema=DATABASE() AND s.index_name='PRIMARY' + GROUP BY s.table_name + HAVING GROUP_CONCAT(s.column_name ORDER BY s.seq_in_index) <> 'id' + ORDER BY s.table_name`); err != nil { + t.Fatal(err) + } + if len(tables) != 0 { + t.Fatalf("IM tables whose primary key is not id: %v", tables) + } + + targets := make([]string, 0, 11) + if err := db.Select(&targets, `SELECT table_name + FROM information_schema.columns + WHERE table_schema=DATABASE() AND column_name='id' AND extra LIKE '%auto_increment%' + AND table_name IN ('im_user_security','im_kvmeta','im_user_profiles','im_user_privacy_settings', + 'im_device_push_tokens','im_friendships','im_user_blocks','im_youth_mode_settings', + 'im_group_settings','im_group_announcement_reads','im_group_mutes') + ORDER BY table_name`); err != nil { + t.Fatal(err) + } + if len(targets) != 0 { + t.Fatalf("snowflake primary keys unexpectedly use AUTO_INCREMENT: %v", targets) + } +} + func TestTenantAuthIsolation(t *testing.T) { tenantAdp, ok := adp.(adapter.TenantAuthAdapter) if !ok { @@ -192,8 +275,8 @@ func TestTenantAuthIsolation(t *testing.T) { userBID := userAID + 1 for index, userID := range []int64{userAID, userBID} { tenantDB := quoteIdent(dbNames[index]) - if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,public,trusted,tags) "+ - "VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, nil, nil, nil); err != nil { + if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,phone,public,trusted) "+ + "VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil { t.Fatal(err) } if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)", @@ -740,8 +823,8 @@ func TestFind(t *testing.T) { if err != nil { t.Error(err) } - if len(got) != 3 { - t.Error(mismatchErrorString("result length", len(got), 3)) + if len(got) != 1 { + t.Error(mismatchErrorString("topic-only result length", len(got), 1)) } } @@ -811,54 +894,13 @@ func TestUserUpdate(t *testing.T) { } func TestUserUpdateTags(t *testing.T) { - addTags := testData.Tags[0] - removeTags := testData.Tags[1] - resetTags := testData.Tags[2] uid := types.ParseUserId("usr" + testData.Users[0].Id) - - got, err := adp.UserUpdateTags(uid, addTags, nil, nil) + got, err := adp.UserUpdateTags(uid, []string{"legacy"}, nil, nil) if err != nil { t.Fatal(err) } - want := []string{"alice", "tag1"} - if !reflect.DeepEqual(got, want) { - t.Error(mismatchErrorString("Tags", got, want)) - - } - got, err = adp.UserUpdateTags(uid, nil, removeTags, nil) - if err != nil { - t.Fatal(err) - } - want = nil - if !reflect.DeepEqual(got, want) { - t.Error(mismatchErrorString("Tags", got, want)) - - } - got, err = adp.UserUpdateTags(uid, nil, nil, resetTags) - if err != nil { - t.Fatal(err) - } - want = []string{"alice", "tag111", "tag333"} - if !reflect.DeepEqual(got, want) { - t.Error(mismatchErrorString("Tags", got, want)) - - } - got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil) - if err != nil { - t.Fatal(err) - } - want = []string{"tag111", "tag333"} - if !reflect.DeepEqual(got, want) { - t.Error(mismatchErrorString("Tags", got, want)) - - } - got, err = adp.UserUpdateTags(uid, addTags, removeTags, nil) - if err != nil { - t.Fatal(err) - } - want = []string{"tag111", "tag333"} - if !reflect.DeepEqual(got, want) { - t.Error(mismatchErrorString("Tags", got, want)) + if got != nil { + t.Fatalf("user tags must stay disabled, got %v", got) } } @@ -1505,6 +1547,11 @@ func init() { if adp.IsOpen() { log.Print("Connection is already opened") } + testData = test_data.InitTestData() + if testData == nil { + log.Fatal("Failed to initialize test data") + } + store.SetTestUidGenerator(*testData.UGen) err := adp.Open(config.Adapters[adp.GetName()]) if err != nil { @@ -1512,9 +1559,4 @@ func init() { } db = adp.GetTestDB().(*sqlx.DB) - testData = test_data.InitTestData() - if testData == nil { - log.Fatal("Failed to initialize test data") - } - store.SetTestUidGenerator(*testData.UGen) } diff --git a/server/db/mysql/user_identity.go b/server/db/mysql/user_identity.go new file mode 100644 index 0000000..3cb6e4f --- /dev/null +++ b/server/db/mysql/user_identity.go @@ -0,0 +1,17 @@ +//go:build mysql +// +build mysql + +package mysql + +func withoutUserTags(update map[string]any) map[string]any { + if update == nil { + return nil + } + clean := make(map[string]any, len(update)) + for key, value := range update { + if key != "Tags" && key != "tags" { + clean[key] = value + } + } + return clean +} diff --git a/server/db/mysql/user_identity_schema.go b/server/db/mysql/user_identity_schema.go new file mode 100644 index 0000000..c086d9d --- /dev/null +++ b/server/db/mysql/user_identity_schema.go @@ -0,0 +1,304 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/tinode/chat/server/store" +) + +const userIdentityMigrationLock = "CONCAT('im_user_identity:', LEFT(SHA2(DATABASE(), 256), 40))" + +type identityValue struct { + TenantID int64 `db:"tenant_id"` + UserID int64 `db:"user_id"` + Value string `db:"value"` +} + +// ensureUserIdentitySchema moves user phone/email out of the legacy tag store. +// Topic tags are intentionally unaffected. +func ensureUserIdentitySchema(db *sqlx.DB) error { + ctx := context.Background() + conn, err := db.Connx(ctx) + if err != nil { + return err + } + defer conn.Close() + + var locked sql.NullInt64 + if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK("+userIdentityMigrationLock+", 60)").Scan(&locked); err != nil { + return err + } + if !locked.Valid || locked.Int64 != 1 { + return errors.New("mysql: timed out waiting for user identity migration lock") + } + defer conn.ExecContext(ctx, "SELECT RELEASE_LOCK("+userIdentityMigrationLock+")") + + usersExist, err := identityTableExists(ctx, conn, "im_users") + if err != nil || !usersExist { + return err + } + usersScoped, err := identityColumnExists(ctx, conn, "im_users", "tenant_id") + if err != nil { + return err + } + profilesExist, err := identityTableExists(ctx, conn, "im_user_profiles") + if err != nil { + return err + } + if !profilesExist { + profileScope := "" + profileUniqueKey := "user_id" + if usersScoped { + profileScope = "tenant_id BIGINT NOT NULL," + profileUniqueKey = "tenant_id,user_id" + } + if _, err = conn.ExecContext(ctx, `CREATE TABLE im_user_profiles( + id BIGINT NOT NULL,`+profileScope+`user_id BIGINT NOT NULL, + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',nickname VARCHAR(64) NOT NULL DEFAULT '', + birth_date DATE,signature VARCHAR(256) NOT NULL DEFAULT '',gender VARCHAR(16) NOT NULL DEFAULT '', + region VARCHAR(128) NOT NULL DEFAULT '',email VARCHAR(128) NOT NULL DEFAULT '', + public_account VARCHAR(64) NOT NULL DEFAULT '',version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id),UNIQUE INDEX uk_im_user_profiles_user(`+profileUniqueKey+`))`); err != nil { + return err + } + profilesExist = true + } + + if exists, err := identityColumnExists(ctx, conn, "im_users", "phone"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent"); err != nil { + return err + } + } + if profilesExist { + if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil { + return err + } else if !exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region"); err != nil { + return err + } + } + } + + if err = backfillUserPhones(ctx, conn, usersScoped); err != nil { + return err + } + if profilesExist { + if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil { + return err + } + } + + indexName := "users_phone" + if usersScoped { + indexName = "uk_im_users_tenant_phone" + } + if exists, err := identityIndexExists(ctx, conn, "im_users", indexName); err != nil { + return err + } else if !exists { + columns := "phone" + if usersScoped { + columns = "tenant_id,phone" + } + if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_users("+columns+")"); err != nil { + return fmt.Errorf("create unique phone index: %w", err) + } + } + + if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil { + return err + } else if exists { + if _, err = conn.ExecContext(ctx, "DROP TABLE im_usertags"); err != nil { + return err + } + } + if exists, err := identityColumnExists(ctx, conn, "im_users", "tags"); err != nil { + return err + } else if exists { + if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users DROP COLUMN tags"); err != nil { + return err + } + } + return nil +} + +func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error { + candidates, err := identityCandidates(ctx, conn, scoped, "tel") + if err != nil { + return err + } + seen := make(map[string]bool) + var existing []identityValue + prefix := "0 AS tenant_id," + if scoped { + prefix = "tenant_id," + } + if err = conn.SelectContext(ctx, &existing, "SELECT "+prefix+"id AS user_id,phone AS value FROM im_users WHERE phone IS NOT NULL AND phone<>''"); err != nil { + return err + } + for _, item := range existing { + seen[identityUniqueKey(item.TenantID, item.Value, scoped)] = true + } + updatedUsers := make(map[string]bool) + for _, item := range candidates { + item.Value = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(item.Value, "tel:"), "basic:")) + userKey := identityUserKey(item.TenantID, item.UserID, scoped) + valueKey := identityUniqueKey(item.TenantID, item.Value, scoped) + if item.Value == "" || updatedUsers[userKey] || seen[valueKey] { + continue + } + query := "UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')" + args := []any{item.Value, item.UserID} + if scoped { + query = "UPDATE im_users SET phone=? WHERE tenant_id=? AND id=? AND (phone IS NULL OR phone='')" + args = []any{item.Value, item.TenantID, item.UserID} + } + result, err := conn.ExecContext(ctx, query, args...) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected > 0 { + updatedUsers[userKey] = true + seen[valueKey] = true + } + } + return nil +} + +func backfillProfileEmails(ctx context.Context, conn *sqlx.Conn, usersScoped bool) error { + candidates, err := identityCandidates(ctx, conn, usersScoped, "email") + if err != nil { + return err + } + 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") + } + processed := make(map[string]bool) + for _, item := range candidates { + item.Value = strings.TrimSpace(strings.TrimPrefix(item.Value, "email:")) + key := identityUserKey(item.TenantID, item.UserID, usersScoped) + if item.Value == "" || processed[key] { + continue + } + processed[key] = true + id, err := nextSnowflakeID() + if err != nil { + return err + } + account := store.EncodeUid(item.UserID).UserId() + if usersScoped { + _, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,tenant_id,user_id,email,public_account) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`, + id, item.TenantID, item.UserID, item.Value, account) + } else { + _, err = conn.ExecContext(ctx, `INSERT INTO im_user_profiles(id,user_id,email,public_account) + VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE email=IF(email='',VALUES(email),email)`, + id, item.UserID, item.Value, account) + } + if 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 { + var rows []identityValue + if err := conn.SelectContext(ctx, &rows, query, args...); err != nil { + return err + } + out = append(out, rows...) + return nil + } + prefix := "0 AS tenant_id,u.id AS user_id," + credentialJoin := "c.userid=u.id" + tagJoin := "ut.userid=u.id" + if scoped { + prefix = "u.tenant_id AS tenant_id,u.id AS user_id," + credentialJoin = "c.tenant_id=u.tenant_id AND c.userid=u.id" + tagJoin = "ut.tenant_id=u.tenant_id AND ut.userid=u.id" + } + if exists, err := identityTableExists(ctx, conn, "im_credentials"); err != nil { + return nil, err + } else if exists { + if err = appendQuery("SELECT "+prefix+"c.value AS value FROM im_users AS u JOIN im_credentials AS c ON "+credentialJoin+ + " WHERE c.method=? AND c.done=1 AND c.deletedat IS NULL ORDER BY u.id,c.updatedat DESC", method); err != nil { + return nil, err + } + } + if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil { + return nil, err + } else if exists { + if err = appendQuery("SELECT "+prefix+"ut.tag AS value FROM im_users AS u JOIN im_usertags AS ut ON "+tagJoin+ + " WHERE ut.tag LIKE ? ORDER BY u.id,ut.id", method+":%"); err != nil { + return nil, err + } + } + if method == "tel" { + if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil { + return nil, err + } else if exists { + authJoin := "a.userid=u.id" + if scoped { + authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id" + } + if err = appendQuery("SELECT " + prefix + "a.uname AS value FROM im_users AS u JOIN im_auth AS a ON " + authJoin + + " WHERE a.scheme='basic' AND (a.uname LIKE '+%' OR a.uname LIKE 'basic:+%') ORDER BY u.id"); err != nil { + return nil, err + } + } + } + return out, nil +} + +func identityTableExists(ctx context.Context, conn *sqlx.Conn, table string) (bool, error) { + var exists int + err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name=?)`, table).Scan(&exists) + return exists == 1, err +} + +func identityColumnExists(ctx context.Context, conn *sqlx.Conn, table, column string) (bool, error) { + var exists int + err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name=? AND column_name=?)`, table, column).Scan(&exists) + return exists == 1, err +} + +func identityIndexExists(ctx context.Context, conn *sqlx.Conn, table, index string) (bool, error) { + var exists int + err := conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name=? AND index_name=?)`, table, index).Scan(&exists) + return exists == 1, err +} + +func identityUserKey(tenantID, userID int64, scoped bool) string { + if scoped { + return fmt.Sprintf("%d:%d", tenantID, userID) + } + return fmt.Sprintf("%d", userID) +} + +func identityUniqueKey(tenantID int64, value string, scoped bool) string { + if scoped { + return fmt.Sprintf("%d:%s", tenantID, value) + } + return value +} diff --git a/server/im_http.go b/server/im_http.go index 78c6e94..82cd62c 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -2128,6 +2128,7 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { Signature *string `json:"signature"` Gender *string `json:"gender"` Region *string `json:"region"` + Email *string `json:"email"` Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { @@ -2146,6 +2147,7 @@ func serveIMMeProfile(wrt http.ResponseWriter, req *http.Request) { Signature: body.Signature, Gender: body.Gender, Region: body.Region, + Email: body.Email, Version: body.Version, }) if err != nil { @@ -4860,14 +4862,14 @@ func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) { } func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { - creds, err := store.Users.GetAllCreds(tenantID, uid, "tel", true) + user, err := store.Users.Get(tenantID, uid) if err != nil { return "", err } - if len(creds) == 0 || strings.TrimSpace(creds[0].Value) == "" { + if user == nil || strings.TrimSpace(user.Phone) == "" { return "", types.ErrNotFound } - return strings.TrimSpace(creds[0].Value), nil + return strings.TrimSpace(user.Phone), nil } func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { @@ -6075,6 +6077,7 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any { "signature": profile.Signature, "gender": profile.Gender, "region": profile.Region, + "email": profile.Email, "public_account": profile.PublicAccount, "version": profile.Version, "updated_at": profile.UpdatedAt.UTC().Format(time.RFC3339Nano), @@ -6083,6 +6086,7 @@ func userProfilePayload10(profile *types.UserProfile) map[string]any { "signature": map[string]any{"max_length": 120}, "birth_date": map[string]any{"format": "YYYY-MM-DD"}, "gender": []string{"male", "female", "other", ""}, + "email": map[string]any{"max_length": 128}, }, } return data diff --git a/server/im_http_test.go b/server/im_http_test.go index ca8fe08..6141c8c 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -593,6 +593,9 @@ func (s *testProfilesStore) Update(_ types.TenantID, uid types.Uid, patch types. if patch.Signature != nil { profile.Signature = *patch.Signature } + if patch.Email != nil { + profile.Email = *patch.Email + } profile.Version = patch.Version + 1 profile.UpdatedAt = time.Now().UTC().Round(time.Millisecond) return profile, nil @@ -2023,7 +2026,7 @@ func TestServeIMMeProfile(t *testing.T) { store.Profiles = profiles defer func() { store.Profiles = oldProfiles }() - req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","version":1}`)) + req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","email":"me@example.com","version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeProfile(rec, req) @@ -2032,7 +2035,7 @@ func TestServeIMMeProfile(t *testing.T) { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["nickname"] != "新昵称" || data["version"].(float64) != 2 { + if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 { t.Fatalf("unexpected profile: %#v", data) } } diff --git a/server/session_test.go b/server/session_test.go index c218fd7..8f871f8 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -1236,24 +1236,25 @@ func TestDispatchAccNew(t *testing.T) { uid := types.Uid(1) oldSecurity := store.Security + oldVerifications := store.Verifications store.Store = ss store.Users = uu store.Security = &testSecurityStore{version: 1} + store.Verifications = nil defer func() { store.Store = nil store.Users = nil store.Security = oldSecurity + store.Verifications = oldVerifications ctrl.Finish() }() remoteAddr := "192.168.0.1" secret := "<==auth-secret==>" - tags := []string{"tag1", "tag2"} authRec := &auth.Rec{ TenantID: 1, Uid: uid, AuthLevel: auth.LevelAuth, - Tags: tags, State: types.StateOK, } ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) @@ -1271,7 +1272,6 @@ func TestDispatchAccNew(t *testing.T) { ss.EXPECT().GetLogicalAuthHandler("token").Return(aa) token := "<==auth-token==>" aa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil) - uu.EXPECT().UpdateTags(types.TenantID(1), uid, tags, nil, nil).Return(tags, nil) s := &Session{ send: make(chan any, 10), @@ -1293,7 +1293,6 @@ func TestDispatchAccNew(t *testing.T) { User: "newXYZ", Scheme: "basic", Secret: []byte(secret), - Tags: []string{"abc", "123"}, Desc: &MsgSetDesc{Public: public}, }, } diff --git a/server/store/store.go b/server/store/store.go index 0515bd2..d695eba 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2034,7 +2034,7 @@ func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool { } func validUserProfilePatch(patch types.UserProfilePatch) bool { - if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil { + if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil && patch.Email == nil { return false } if patch.Nickname != nil { @@ -2046,6 +2046,9 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool { if patch.Signature != nil && len([]rune(*patch.Signature)) > 120 { return false } + if patch.Email != nil && len([]rune(strings.TrimSpace(*patch.Email))) > 128 { + return false + } return true } diff --git a/server/store/types/profile.go b/server/store/types/profile.go index 618e3a0..d72f7db 100644 --- a/server/store/types/profile.go +++ b/server/store/types/profile.go @@ -10,6 +10,7 @@ type UserProfile struct { Signature string Gender string Region string + Email string PublicAccount string Version int CreatedAt time.Time @@ -23,6 +24,7 @@ type UserProfilePatch struct { Signature *string Gender *string Region *string + Email *string Version int } diff --git a/server/store/types/types.go b/server/store/types/types.go index 56104e2..d9577d0 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -519,6 +519,8 @@ type User struct { LastSeen *time.Time // User agent provided when accessing the topic last time UserAgent string + // Canonical mobile number used for registration and account recovery. + Phone string Public any Trusted any diff --git a/server/tinode.conf b/server/tinode.conf index 726e7ed..4cfb92e 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -45,7 +45,7 @@ "auth_config": { "logical_names": [], "basic": { - "add_to_tags": true, + "add_to_tags": false, "min_login_length": 4, "min_password_length": 6 }, @@ -80,7 +80,7 @@ "acc_validation": { "email": { - "add_to_tags": true, + "add_to_tags": false, "required": [], "config": { "host_url": "http://64.118.137.182:6060/", @@ -89,7 +89,7 @@ } }, "tel": { - "add_to_tags": true, + "add_to_tags": false, "required": [], "config": { "host_url": "http://64.118.137.182:6060/", diff --git a/server/topic.go b/server/topic.go index 74ba173..49ec6e5 100644 --- a/server/topic.go +++ b/server/topic.go @@ -3071,6 +3071,10 @@ func (t *Topic) replySetTags(sess *Session, asUid types.Uid, msg *ClientComMessa sess.queueOut(ErrOperationNotAllowedReply(msg, now)) return errors.New("invalid topic category to assign tags") } + if t.cat == types.TopicCatMe { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("user tags are no longer supported") + } if t.cat == types.TopicCatGrp && t.owner != asUid { sess.queueOut(ErrPermissionDeniedReply(msg, now)) @@ -3168,7 +3172,6 @@ func (t *Topic) replyGetCreds(sess *Session, asUid types.Uid, msg *ClientComMess sess.queueOut(ErrOperationNotAllowedReply(msg, now)) return errors.New("invalid topic category for getting credentials") } - screds, err := store.Users.GetAllCreds(sess.tenantID, asUid, "", false) if err != nil { sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, msg.Timestamp, nil)) @@ -3206,6 +3209,10 @@ func (t *Topic) replySetCred(sess *Session, asUid types.Uid, authLevel auth.Leve sess.queueOut(ErrOperationNotAllowedReply(msg, now)) return errors.New("invalid topic category for updating credentials") } + if set.Cred.Method == "tel" || set.Cred.Method == "email" { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("phone and email are managed by product profile APIs") + } var err error var tags []string @@ -3484,6 +3491,10 @@ func (t *Topic) replyDelCred(sess *Session, asUid types.Uid, authLvl auth.Level, sess.queueOut(ErrMalformedReply(msg, now)) return errors.New("del.cred: missing method") } + if del.Cred.Method == "tel" || del.Cred.Method == "email" { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("phone and email are managed by product profile APIs") + } tags, err := deleteCred(sess.tenantID, asUid, authLvl, del.Cred) if tags != nil { diff --git a/server/user.go b/server/user.go index 57165f1..38ba5d1 100644 --- a/server/user.go +++ b/server/user.go @@ -73,31 +73,47 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { user.State = state } - // Ensure tags are unique and not restricted. - if tags := normalizeTags(msg.Acc.Tags, globals.maxTagCount); tags != nil { - if !restrictedTagsEqual(tags, nil, globals.immutableTagNS) { - logs.Warn.Println("create user: attempt to directly assign restricted tags, sid=", s.sid) - msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp) - msg.Ctrl.Params = map[string]any{"what": "tags"} - s.queueOut(msg) - return - } - user.Tags = tags + if msg.Acc.Tags != nil { + logs.Warn.Println("create user: user tags are no longer supported, sid=", s.sid) + msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp) + msg.Ctrl.Params = map[string]any{"what": "tags"} + s.queueOut(msg) + return } // Pre-check credentials for validity. We don't know user's access level // consequently cannot check presence of required credentials. Must do that later. creds := normalizeCredentials(msg.Acc.Cred, true) + var registrationPhone string for i := range creds { cr := &creds[i] vld := store.Store.GetValidator(cr.Method) - if _, err := vld.PreCheck(cr.Value, cr.Params); err != nil { + normalized, err := vld.PreCheck(cr.Value, cr.Params) + if err != nil { logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, map[string]any{"what": cr.Method})) return } + if cr.Method == "tel" { + phone := strings.TrimSpace(strings.TrimPrefix(normalized, "tel:")) + challengeID, _ := cr.Params["challenge_id"].(string) + if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" || + (registrationPhone != "" && registrationPhone != phone) { + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"creds": []string{"tel"}})) + return + } + registrationPhone = phone + } } + if registrationPhone == "" && store.Verifications != nil { + logs.Warn.Println("create user: verified phone is required, sid=", s.sid) + s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp, + map[string]any{"creds": []string{"tel"}})) + return + } + user.Phone = registrationPhone // Assign default access values in case the acc creator has not provided them user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) | @@ -138,9 +154,9 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { return } - // Add authentication record. The authhdl.AddRecord may change tags. + // Add authentication record. rec, err := authhdl.AddRecord(authCtx, - &auth.Rec{TenantID: s.tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret) + &auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret) if err != nil { logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) @@ -167,14 +183,14 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { return } - // Save credentials, update tags if necessary. + // Save and validate credentials. tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{ TenantID: s.tenantID, Uid: user.Uid(), AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour * 24), }) - validated, _, err := addCreds(s.tenantID, user.Uid(), creds, rec.Tags, s.lang, tmpToken) + validated, _, err := addCreds(s.tenantID, user.Uid(), creds, nil, s.lang, tmpToken) if err != nil { logs.Warn.Println("create user: failed to save or validate credential", err, "sid=", s.sid) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) @@ -351,17 +367,11 @@ func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types. // TODO(gene): support adding new auth schemes - rec, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr}, - &auth.Rec{TenantID: tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret) + _, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr}, + &auth.Rec{TenantID: tenantID, Uid: user.Uid()}, msg.Acc.Secret) if err != nil { return err } - - // Tags may have been changed by authhdl.UpdateRecord, reset them. - // Can't do much with the error here, logging it but not returning. - if _, err = store.Users.UpdateTags(tenantID, user.Uid(), nil, nil, rec.Tags); err != nil { - logs.Warn.Println("updateUserAuth tags update failed:", err) - } return nil } @@ -369,11 +379,8 @@ func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types. return types.ErrMalformed } -// addCreds adds new credentials and re-send validation request for existing ones. -// It also adds credential-defined tags if necessary. -// Returns methods validated in this call only. Returns either a full set of tags -// or nil for tags when tags are unchanged. -func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, extraTags []string, +// addCreds adds credentials and re-sends validation requests for existing ones. +func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, _ []string, lang string, tmpToken []byte) ([]string, []string, error) { var validated []string for i := range creds { @@ -385,14 +392,11 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext } if cr.Response != "" { - if value, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil { + if _, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil { if err != nil { return nil, nil, err } validated = append(validated, cr.Method) - if globals.validators[cr.Method].addToTags { - extraTags = append(extraTags, cr.Method+":"+value) - } continue } } @@ -407,24 +411,9 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext // successfully validated. validated = append(validated, cr.Method) - // Generate tags for these confirmed credentials. - if globals.validators[cr.Method].addToTags { - extraTags = append(extraTags, cr.Method+":"+cr.Value) - } } } - - // Save tags potentially changed by the validator. - if len(extraTags) > 0 { - if utags, err := store.Users.UpdateTags(tenantID, uid, extraTags, nil, nil); err == nil { - extraTags = utags - } else { - logs.Warn.Println("add cred tags update failed:", err) - } - } else { - extraTags = nil - } - return validated, extraTags, nil + return validated, nil, nil } func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient, @@ -475,7 +464,6 @@ func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr * // validatedCreds returns the list of validated credentials including those validated in this call. // Returns all validated methods including those validated earlier and now. -// Returns either a full set of tags or nil for tags if tags are unchanged. func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, creds []MsgCredClient, errorOnFail bool) ([]string, []string, error) { // Check if credential validation is required. @@ -497,7 +485,6 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, // Add credentials which are validated in this call. // Unknown validators are removed. creds = normalizeCredentials(creds, false) - var tagsToAdd []string for i := range creds { cr := &creds[i] if cr.Response == "" { @@ -506,7 +493,7 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, } vld := store.Store.GetValidator(cr.Method) // No need to check for nil, unknown methods are removed earlier. - value, err := vld.Check(tenantID, uid, cr.Response) + _, err := vld.Check(tenantID, uid, cr.Response) if err != nil { // Check failed. @@ -525,23 +512,6 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, // Check did not return an error: the request was successfully validated. methods[cr.Method] = struct{}{} - // Add validated credential to user's tags. - if globals.validators[cr.Method].addToTags { - tagsToAdd = append(tagsToAdd, cr.Method+":"+value) - } - } - - var tags []string - if len(tagsToAdd) > 0 { - // Save update to tags - if utags, err := store.Users.UpdateTags(tenantID, uid, tagsToAdd, nil, nil); err == nil { - tags = utags - } else { - logs.Warn.Println("validated creds tags update failed:", err) - tags = nil - } - } else { - tags = nil } validated := make([]string, 0, len(methods)) @@ -549,11 +519,10 @@ func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, validated = append(validated, method) } - return validated, tags, nil + return validated, nil, nil } // deleteCred deletes user's credential. -// Returns full set of remaining tags or nil if tags are unchanged. func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) { vld := store.Store.GetValidator(cred.Method) if vld == nil || cred.Value == "" { @@ -601,21 +570,7 @@ func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred return nil, err } - // Remove generated tags for the deleted credential. - var tags []string - if globals.validators[cred.Method].addToTags { - // This error should not be returned to user. - if utags, err := store.Users.UpdateTags(tenantID, uid, nil, []string{cred.Method + ":" + cred.Value}, nil); err == nil { - tags = utags - } else { - logs.Warn.Println("delete cred: failed to update tags:", err) - tags = nil - } - } else { - tags = nil - } - - return tags, nil + return nil, nil } // Change user state: suspended/normal (ok).