From f2c229135be237315bbfe10da8f0e02c1f43dd7a Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Mon, 17 Aug 2026 13:43:27 +0800 Subject: [PATCH] feat: complete IM P0 and P1 reliability work --- chell要点.txt | 33 + docs/IM-数据库变更汇总.sql | 70 +- ...章后续增强项.md => IM-未实现后续增强项.md} | 73 +- docs/IM-第一至七章数据库增量审核.sql | 72 +- docs/IM-系统设计文档.md | 2 +- docs/IM-群聊业务表设计.sql | 28 +- docs/IM-需求实现情况审核报告.md | 434 ++++++++++ .../IM-多租户第5至12章代码改造与测试方案.md | 0 .../{ => obsolete}/IM-服务端多租户改造方案.md | 6 +- .../mysql-multitenant-tenant-id-review.sql | 162 ++-- scripts/apply_group_business_tables.go | 9 +- scripts/check_group_business_tables.go | 9 +- server/db/adapter.go | 22 +- server/db/mysql/adapter.go | 389 +++++---- server/db/mysql/group_schema.go | 154 ++++ server/db/mysql/group_schema_test.go | 33 + server/db/mysql/infrastructure_schema.go | 39 + server/db/mysql/infrastructure_schema_test.go | 27 + server/db/mysql/schema.sql | 3 +- server/db/mysql/tenant_adapter.go | 527 +++++++++--- server/db/mysql/tenant_groups.go | 540 +++++++++++- server/db/mysql/tenant_infrastructure.go | 253 ++++++ server/db/mysql/tests/mysql_test.go | 231 ++++-- server/db/postgres/adapter.go | 584 ++++++------- server/db/postgres/tests/postgres_test.go | 90 +- server/http.go | 4 +- server/im_business_events.go | 120 +++ server/im_group_events.go | 81 ++ server/im_http.go | 781 +++++++++++++----- server/im_http_test.go | 321 ++++++- server/main.go | 2 +- server/push/fcm/README.md | 7 + server/push/fcm/payload.go | 17 +- server/push/fcm/payload_devices_test.go | 65 ++ server/push/fcm/push_fcm.go | 8 + server/session_test.go | 4 + server/store/infrastructure.go | 81 ++ server/store/store.go | 251 +++++- server/store/tenant_store_test.go | 28 + server/store/types/groups.go | 16 + server/store/types/infrastructure.go | 56 ++ server/topic.go | 25 +- server/topic_test.go | 36 +- 43 files changed, 4512 insertions(+), 1181 deletions(-) create mode 100644 chell要点.txt rename docs/{IM-第一二章后续增强项.md => IM-未实现后续增强项.md} (79%) create mode 100644 docs/IM-需求实现情况审核报告.md rename docs/{ => obsolete}/IM-多租户第5至12章代码改造与测试方案.md (100%) rename docs/{ => obsolete}/IM-服务端多租户改造方案.md (99%) create mode 100644 server/db/mysql/group_schema.go create mode 100644 server/db/mysql/group_schema_test.go create mode 100644 server/db/mysql/infrastructure_schema.go create mode 100644 server/db/mysql/infrastructure_schema_test.go create mode 100644 server/db/mysql/tenant_infrastructure.go create mode 100644 server/im_business_events.go create mode 100644 server/im_group_events.go create mode 100644 server/push/fcm/payload_devices_test.go create mode 100644 server/store/infrastructure.go create mode 100644 server/store/types/infrastructure.go diff --git a/chell要点.txt b/chell要点.txt new file mode 100644 index 0000000..d1e8717 --- /dev/null +++ b/chell要点.txt @@ -0,0 +1,33 @@ +这些图片中的内容是关于IM即时通讯的软件,你识别图片中的内容,整理为需求文档,包含图片,功能说明 +上面是一张图片,下面就是说明,功能的标题就是图片的名称,说明的时候将功能相关的放一起,我希望是这样的 +1.相关功能图片的总体功能(需要你总结一个标题) +1.1 图片名称 +图片 +功能说明: +图片内容的描述 +写成一个md文档,要在md文档中可以看到图片 + + +密码aes 加解密 密码能够加解密 +聊天内容加密 +聊天内容的监控 +用户的类别 +后台生成的有标识 +登录设备做标识 移动 +登基日志 连续N天设备登录 +多通过配置的方式处理 前端不要写死 + +一定要考虑好群聊的推送问题 +离线和在线状态存到redis里面 +离线的不推 +离线的我们的服务不推 走极光的推送 +多用开关 + +admin后台群操作通过kafka投递到c端 c端通过ws传到客户端 +记录先放到Kafka、 再异步落库。用考虑重复落库的可能 + +1. 有 IM 即时通讯项目开发经验优先; +2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理; +3. 熟悉群消息扩散策略,如读扩散、写扩散; +4. 理解多端同步、消息漫游、消息推送等 IM 核心机制; +5. 有 OpenIM 或类似 IM 项目经验优先。 \ No newline at end of file diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index 3d068e1..5621bcc 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -159,6 +159,44 @@ CREATE TABLE IF NOT EXISTS tinode.im_system_notifications ( INDEX idx_im_system_notifications_inbox (user_id, id DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +CREATE TABLE IF NOT EXISTS tinode.im_http_idempotency ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + method VARCHAR(8) NOT NULL, + route VARCHAR(512) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + body_hash CHAR(64) NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'processing', + response_status INT NOT NULL DEFAULT 0, + response_body MEDIUMBLOB, + expires_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 (id), + UNIQUE INDEX uk_im_http_idempotency_scope (user_id, method, route, idempotency_key), + INDEX idx_im_http_idempotency_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_business_events_outbox ( + id BIGINT NOT NULL AUTO_INCREMENT, + event_id VARCHAR(64) NOT NULL, + target_type VARCHAR(16) NOT NULL, + target VARCHAR(128) NOT NULL, + actor_user_id BIGINT NOT NULL DEFAULT 0, + event_type VARCHAR(64) NOT NULL, + payload JSON, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3), + delivered_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 (id), + UNIQUE INDEX uk_im_business_events_outbox_event (event_id), + INDEX idx_im_business_events_outbox_pending (status, next_retry_at, id), + INDEX idx_im_business_events_outbox_target (target_type, target, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + CREATE TABLE IF NOT EXISTS tinode.im_message_revisions ( id BIGINT NOT NULL AUTO_INCREMENT, createdat DATETIME(3) NOT NULL, @@ -239,11 +277,11 @@ CREATE TABLE IF NOT EXISTS tinode.im_favorites ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- Existing Tinode tables must match the current schema for chapters 2-7: --- topics.aux, fileuploads.etag, and filemsglinks.topic/userid are required by current code. +-- im_topics.aux, im_fileuploads.etag, and im_filemsglinks.topic/userid are required by current code. SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'client_msg_id') = 0, - 'ALTER TABLE tinode.messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`', + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'client_msg_id') = 0, + 'ALTER TABLE tinode.im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -251,8 +289,8 @@ EXECUTE im_stmt; DEALLOCATE PREPARE im_stmt; SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'version') = 0, - 'ALTER TABLE tinode.messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id', + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'version') = 0, + 'ALTER TABLE tinode.im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -260,8 +298,8 @@ EXECUTE im_stmt; DEALLOCATE PREPARE im_stmt; SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_at') = 0, - 'ALTER TABLE tinode.messages ADD COLUMN recalled_at DATETIME(3) AFTER version', + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'recalled_at') = 0, + 'ALTER TABLE tinode.im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -269,8 +307,8 @@ EXECUTE im_stmt; DEALLOCATE PREPARE im_stmt; SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_by') = 0, - 'ALTER TABLE tinode.messages ADD COLUMN recalled_by BIGINT AFTER recalled_at', + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'recalled_by') = 0, + 'ALTER TABLE tinode.im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -278,8 +316,8 @@ EXECUTE im_stmt; DEALLOCATE PREPARE im_stmt; SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0, - 'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.messages(`from`, client_msg_id)', + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0, + 'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.im_messages(`from`, client_msg_id)', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -291,8 +329,8 @@ DROP TABLE IF EXISTS tinode.im_tenant_config; DROP TABLE IF EXISTS tinode.im_tenant; SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'auth' AND COLUMN_NAME = 'tenant_id') > 0, - 'ALTER TABLE tinode.auth DROP COLUMN tenant_id', + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_auth' AND COLUMN_NAME = 'tenant_id') > 0, + 'ALTER TABLE tinode.im_auth DROP COLUMN tenant_id', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -300,8 +338,8 @@ EXECUTE im_stmt; DEALLOCATE PREPARE im_stmt; SET @im_sql = IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'credentials' AND COLUMN_NAME = 'tenant_id') > 0, - 'ALTER TABLE tinode.credentials DROP COLUMN tenant_id', + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_credentials' AND COLUMN_NAME = 'tenant_id') > 0, + 'ALTER TABLE tinode.im_credentials DROP COLUMN tenant_id', 'SELECT 1' ); PREPARE im_stmt FROM @im_sql; @@ -732,4 +770,4 @@ VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"pu ON DUPLICATE KEY UPDATE updated_at = updated_at; -- Current MySQL adapter schema version expected by server/db/mysql/adapter.go. -UPDATE tinode.kvmeta SET `value` = 123 WHERE `key` = 'version'; +UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version'; diff --git a/docs/IM-第一二章后续增强项.md b/docs/IM-未实现后续增强项.md similarity index 79% rename from docs/IM-第一二章后续增强项.md rename to docs/IM-未实现后续增强项.md index 8005911..190835e 100644 --- a/docs/IM-第一二章后续增强项.md +++ b/docs/IM-未实现后续增强项.md @@ -1,8 +1,9 @@ # IM 第一至十一章后续增强项 -> 日期:2026-08-13 +> 更新日期:2026-08-17 > 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈 > 用途:记录当前已完成基线之外的增强项,后续统一排期处理。 +> 状态说明:2026-08-17 已完成的 P0/P1 已移入各章“当前已完成基线”,不再作为后续待办。完整修复记录见 `IM-需求实现情况审核报告.md`。 ## 1. 第一章:登录、注册与安全 @@ -17,15 +18,17 @@ 7. 实名认证状态查询与提交,证件信息脱敏/保护存储。 8. 独立密码设置与验证,包含弱密码规则和失败锁定。 9. 登录后 bootstrap 返回实名、安全与菜单基础能力。 +10. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。 +11. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。 +12. 风险确认已生成可重试安全事件和用户系统通知。 ### 1.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | HTTP `Idempotency-Key` 统一存储与请求摘要冲突检测 | 当前部分接口要求幂等键,但还没有统一的 `tenant_id + uid + method + route + key + body_hash` 处理层。 | 相同键相同 body 返回同一结果;相同键不同 body 返回冲突。 | | P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 | | P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | -| P0 | 安全事件通知联动 | 密码重置、实名提交/审核、独立密码变更、新设备登录等应写入系统通知。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件。 | +| P1 | 安全事件通知覆盖扩展 | 风险确认已接入统一事件和系统通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件且重试不重复入箱。 | | P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 | | P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 | | P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 | @@ -39,25 +42,24 @@ 1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。 2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。 3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。 -4. 消息 Header 基础校验:`x-im-schema`、`type`、`x-im-client-mid`。 -5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `Head.type=system`。 +4. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。 +5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。 6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。 7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。 8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。 9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。 +10. 消息编辑/撤回已在事务内写入通用 Outbox,并向 Topic 在线成员派发可去重事件。 +11. 编辑/撤回已接入全局 HTTP 幂等和响应重放。 +12. 好友、群、会议和安全模块已可信生产系统通知。 ### 2.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | 编辑/撤回多端实时事件广播 | 当前 HTTP 更新 DB 并返回成功,后续需要向 Topic 在线成员广播编辑/撤回事件。 | A 端撤回/编辑后,B 端无需刷新即可收到事件并更新 UI。 | -| P0 | 编辑/撤回事件 Outbox | DB 更新和实时广播应通过事务事件或 Outbox 衔接,避免更新成功但事件丢失。 | 模拟广播失败后,后台任务可补发事件。 | | P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 | -| P0 | `Idempotency-Key` 接入编辑/撤回 | 编辑/撤回目前依赖乐观版本控制,后续需要同时支持 HTTP 幂等键。 | 客户端超时重试同一请求不会重复写 revision。 | | P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | | P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 | | P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 | -| P1 | 系统通知可信生产通道 | 当前已提供通知存储和读取接口,后续需要统一服务端生产 API/事件消费者,禁止业务散落直写。 | 安全、群、组织等模块通过统一通道创建通知。 | | P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 | | P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 | | P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | @@ -72,17 +74,18 @@ 3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。 4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。 +6. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。 +7. 好友申请/审批关键路径已在业务事务内写入通知和 Outbox,其他可重复关系写由全局 HTTP 幂等保证可补写。 +8. 非好友资料查询已执行 `profile_visibility`。 ### 3.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | -| P0 | 关系变更实时同步 | 好友通过、删除、拉黑、解除拉黑后,应向双方在线端推送关系和会话能力变化。 | A 拉黑 B 后,B 端输入框无需刷新自动锁定。 | -| P0 | 关系事件 Outbox | 关系 DB 状态变化与系统通知、会话事件、Push 需要事务级衔接。 | 模拟推送失败后后台可补发,不产生重复事件。 | | P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 | | P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | -| P1 | 非好友资料隐私裁剪 | 单聊上下文中的对端资料需要接入隐私设置、企业可见性和青少年模式。 | 非好友只能看到允许公开字段;好友看到扩展字段。 | +| P1 | 资料策略继续扩展 | 好友/非好友可见性已执行,企业字段级可见性和青少年模式仍需纳入同一策略。 | 企业受限字段与青少年用户无法从其他入口绕过策略。 | | P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | | P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 | @@ -95,6 +98,7 @@ 3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。 4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。 5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。 +6. 群昵称、角色、禁言、成员和公告等群业务变更已写入群 Outbox,并生成在线事件与系统通知。 ### 4.2 后续增强项 @@ -104,7 +108,6 @@ | P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 | | P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 | | P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 | -| P1 | 群昵称/角色变更事件 | 群昵称、角色、禁言、移除等变化需要实时推送给群成员并刷新成员列表缓存。 | 群主修改成员角色后在线端成员列表自动更新。 | | P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 | | P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 | | P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 | @@ -121,20 +124,21 @@ 6. 群内非好友个人主页返回群角色、关系、资料和操作能力。 7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。 8. 黑名单列表和批量移出黑名单已具备。 +9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。 +10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。 +11. 好友个人主页已执行 `profile_visibility`。 ### 5.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | 好友/黑名单操作统一幂等 | 拉黑、解除、删除、批量移出、好友申请等需要统一接入 `Idempotency-Key` 存储和请求摘要。 | 客户端超时重试不会重复创建申请或重复触发事件。 | -| P0 | 好友关系实时事件 | 申请、通过、拒绝、删除、备注、拉黑等状态变化需要向相关在线端实时同步。 | 两端关系页、会话页和通讯录无需刷新自动更新。 | -| P0 | 用户搜索隐私策略 | 当前搜索基线可用,后续需要接入 `allow_user_search`、添加方式、手机号可搜索等租户和个人隐私设置。 | 禁止手机号添加的用户无法通过手机号被搜索到,且不泄露存在性。 | +| P1 | 用户搜索租户策略扩展 | 个人添加方式已执行,后续还需增加租户级 `allow_user_search`、通讯录授权和存在性防探测策略。 | 租户关闭搜索或未授权通讯录时,客户端无法绕过且不泄露用户存在性。 | | P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 | -| P1 | 好友资料字段精细裁剪 | 个人主页需按好友、非好友、群内关系、黑名单、实名/青少年等规则返回不同字段。 | 不同入口返回字段符合隐私矩阵。 | +| P1 | 好友资料字段精细裁剪 | 基础 `everyone/friends/nobody` 可见性已执行,后续需按群内关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 | | P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 | | P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 | | P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 | -| P1 | 关系审计和安全通知 | 添加、删除、拉黑、解除、二维码解析等应写审计并按策略生成系统通知。 | 用户可在通知中看到关键关系事件,后台可追溯。 | +| P1 | 关系操作审计 | 关键关系系统通知已接入,添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 | | P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 | ## 6. 第六章:群聊创建、资料维护与成员治理 @@ -149,21 +153,18 @@ 6. 群治理配置 `GET/PATCH /governance` 已具备。 7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。 8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 -9. 当前实现优先复用 Topic、Subscription 和 Topic Aux,把产品主链路接口跑通。 +9. 12 张 `im_group_*` 业务表已纳入 MySQL 租户库自动初始化,独立租户库模式不保存 `tenant_id`。 +10. 群邀请、入群申请/审批、成员变更、角色变更、群主转让和退群已收口到 MySQL 事务。 +11. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。 +12. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。 +13. 群公告已使用独立表并记录成员已读时间。 ### 6.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | 群业务独立表拆分 | 当前群公告、进群申请、治理配置、禁言记录先存 Topic Aux,后续需要拆为独立表。 | 公告、申请、治理、禁言均有 tenant/topic 维度索引,可稳定分页和审计。 | -| P0 | 群业务表设计落地 | 已整理 `docs/IM-群聊业务表设计.sql`,后续需要按该设计接入迁移脚本和 store/adapter。 | 表结构审核通过后,群接口从 Topic Aux 切到业务表。 | -| P0 | 群治理强事务状态机 | 邀请、申请审批、管理员变更、群主转让、禁言需在事务内同步业务表、订阅 ACL、成员数和事件。 | 并发审批/转让/禁言不会出现 ACL 与业务状态不一致。 | -| P0 | 第六章幂等键落库 | 邀请、申请处理、公告发布、治理 PATCH、管理员批量、群主转让、禁言批量都需要统一幂等。 | 超时重试同一请求返回同一结果,不重复加人、升权或发事件。 | -| P0 | 系统事件、Push 与 Outbox | 第六章所有成员/公告/治理变化需要生成可信群系统事件,并通过 Outbox 保证推送。 | 在线端实时刷新;模拟推送失败后可补偿且不重复。 | -| P1 | 群公告已读独立记录 | 当前公告已读返回基础结果,后续需记录每个成员的公告已读时间。 | 新公告只提醒未读成员,已读状态跨端同步。 | | P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | | P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | -| P1 | 禁言发送链路强校验 | 当前禁言接口已具备,后续需要在 WebSocket 发消息路径校验 `GROUP_MUTED`。 | 被禁言成员发群消息被服务端拒绝,过期后自动恢复。 | | P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 | | P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | | P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 | @@ -177,12 +178,13 @@ 3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。 4. 消息上下文定位接口可围绕指定 seq 返回前后消息。 5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。 +6. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。 ### 7.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | 收藏幂等与去重策略 | 收藏同一消息、同一文件或同一链接需要统一去重和幂等键处理。 | 重复点击收藏不会产生重复记录;超时重试返回同一收藏 ID。 | +| P1 | 收藏业务去重策略 | 同一 HTTP 请求的超时重试已由全局幂等保证;使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 | | P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 | | P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 | | P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 | @@ -229,6 +231,8 @@ 6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。 7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 +9. 工作台 `tenant_code` 已返回租户注册表的公开编码,不再暴露内部 ID。 +10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。 ### 9.2 后续增强项 @@ -240,7 +244,7 @@ | P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | | P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 | | P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 | -| P1 | 会议通知实时提醒 | 当前接口和表已具备,后续需要会议创建、更新、取消、响应后的在线事件和 Push。 | 参与人在线端无需刷新即可收到会议变化。 | +| P1 | 会议离线 Push 联调 | 会议在线事件、系统通知和 Outbox 已完成,后续需使用真实 Firebase 凭据和移动端验证离线通知。 | 参与人离线时收到一次提醒,点击可跳转至正确会议。 | | P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | | P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | | P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | @@ -255,6 +259,8 @@ 3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。 4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。 5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。 +6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。 +7. 用户搜索/加好友、个人资料、消息回执及 Tinode P2P 最后在线输出已执行对应隐私设置。 ### 10.2 后续增强项 @@ -265,7 +271,7 @@ | P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 | | P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 | | P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 | -| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 | +| P1 | 隐私策略入口扩展 | 产品主要搜索、资料、回执和最后在线入口已执行隐私设置,组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 | | P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 | ## 11. 反诈安全、举报与意见反馈 @@ -277,6 +283,7 @@ 3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。 4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。 5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。 +6. 风险确认结果已生成稳定 `security.risk.*` 事件和用户可见系统通知。 ### 11.2 后续增强项 @@ -287,13 +294,13 @@ | P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 | | P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 | | P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 | -| P1 | 反诈系统事件可信生产 | 低风险提示需通过可信 `security.risk_notice` 系统事件发送,并限制普通消息伪装。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 | +| P1 | 主动反诈风险提示 | 风险确认通知已由服务端可信生产;低风险主动提示、通知文案策略和 Push 优先级仍需扩展。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 | | P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 | | P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 | ## 12. 统一处理建议 -1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。 -2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。 +1. 优先剩余 P0:验证码真实发送与多维限频、消息撤回历史占位、P2P 文件/通话策略统一、@通知与频控、文件安全与短时授权、通话强状态机、工作台/组织权限和风控 token 业务消费。 +2. 再做剩余 P1:租户策略配置、实名审核、设备/会话管理、字段级隐私矩阵、关系审计、群文件与解散状态机、通话质量指标、报表/信箱后台流转和真实 FCM 离线联调。 3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。 -4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。 +4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和 `IM-需求实现情况审核报告.md`,避免已完成项继续出现在待办清单。 diff --git a/docs/IM-第一至七章数据库增量审核.sql b/docs/IM-第一至七章数据库增量审核.sql index bb6c62b..f0f97b5 100644 --- a/docs/IM-第一至七章数据库增量审核.sql +++ b/docs/IM-第一至七章数据库增量审核.sql @@ -269,48 +269,48 @@ CREATE TABLE IF NOT EXISTS im_message_revisions( -- 如果是旧库,需要逐项核对后再 ALTER。 -- ============================================================ --- users:租户隔离字段和索引。 --- ALTER TABLE users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- CREATE INDEX users_tenant_id_id ON users(tenant_id, id); +-- im_users:租户隔离字段和索引。 +-- ALTER TABLE im_users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX im_users_tenant_id_id ON im_users(tenant_id, id); --- topics:租户隔离、群治理 Aux、租户维度唯一索引。 --- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 topics.aux JSON。 +-- im_topics:租户隔离、群治理 Aux、租户维度唯一索引。 +-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 im_topics.aux JSON。 -- 后续若要强状态机和审计,应拆 im_group_join_requests、im_group_announcements、im_group_mutes 等表。 --- ALTER TABLE topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- ALTER TABLE topics ADD COLUMN aux JSON AFTER tags; --- CREATE UNIQUE INDEX topics_tenant_name ON topics(tenant_id, name); --- CREATE INDEX topics_tenant_owner ON topics(tenant_id, owner); +-- ALTER TABLE im_topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE im_topics ADD COLUMN aux JSON AFTER tags; +-- CREATE UNIQUE INDEX im_topics_tenant_name ON im_topics(tenant_id, name); +-- CREATE INDEX im_topics_tenant_owner ON im_topics(tenant_id, owner); --- subscriptions:租户字段和按租户查成员索引。 --- ALTER TABLE subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- CREATE INDEX subscriptions_tenant_userid ON subscriptions(tenant_id, userid); --- CREATE INDEX subscriptions_tenant_topic ON subscriptions(tenant_id, topic); --- CREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat); +-- im_subscriptions:租户字段和按租户查成员索引。 +-- ALTER TABLE im_subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX im_subscriptions_tenant_userid ON im_subscriptions(tenant_id, userid); +-- CREATE INDEX im_subscriptions_tenant_topic ON im_subscriptions(tenant_id, topic); +-- CREATE INDEX im_subscriptions_userid_topic_deletedat ON im_subscriptions(userid, topic, deletedat); --- messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。 --- ALTER TABLE messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`; --- ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id; --- ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version; --- ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at; --- CREATE INDEX messages_tenant_topic ON messages(tenant_id, topic); --- CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id); +-- im_messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。 +-- ALTER TABLE im_messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`; +-- ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id; +-- ALTER TABLE im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version; +-- ALTER TABLE im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at; +-- CREATE INDEX im_messages_tenant_topic ON im_messages(tenant_id, topic); +-- CREATE UNIQUE INDEX messages_sender_client_mid ON im_messages(tenant_id, `from`, client_msg_id); --- dellog:用户侧删除过滤需要 tenant_id。 --- ALTER TABLE dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- CREATE INDEX dellog_tenant_topic ON dellog(tenant_id, topic); --- CREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic, deletedfor, low, hi); +-- im_dellog:用户侧删除过滤需要 tenant_id。 +-- ALTER TABLE im_dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX im_dellog_tenant_topic ON im_dellog(tenant_id, topic); +-- CREATE INDEX im_dellog_topic_deletedfor_low_hi ON im_dellog(topic, deletedfor, low, hi); --- fileuploads:文件 ACL、租户隔离、ETag。 --- ALTER TABLE fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128) AFTER size; --- CREATE INDEX fileuploads_tenant_status ON fileuploads(tenant_id, status); +-- im_fileuploads:文件 ACL、租户隔离、ETag。 +-- ALTER TABLE im_fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE im_fileuploads ADD COLUMN etag VARCHAR(128) AFTER size; +-- CREATE INDEX im_fileuploads_tenant_status ON im_fileuploads(tenant_id, status); --- filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。 --- ALTER TABLE filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; --- ALTER TABLE filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid; --- ALTER TABLE filemsglinks ADD COLUMN userid BIGINT AFTER topic; --- CREATE INDEX filemsglinks_tenant_fileid ON filemsglinks(tenant_id, fileid); +-- im_filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。 +-- ALTER TABLE im_filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE im_filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid; +-- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic; +-- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid); --- usertags/topictags/devices/auth/credentials 也已按租户增加 tenant_id; +-- im_usertags/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 6730762..cb4b478 100644 --- a/docs/IM-系统设计文档.md +++ b/docs/IM-系统设计文档.md @@ -4,7 +4,7 @@ > 日期:2026-08-11
> 状态:后续开发基线
> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md)
-> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](./IM-服务端多租户改造方案.md) +> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](obsolete/IM-服务端多租户改造方案.md) | 版本 | 日期 | 变更 | | --- | --- | --- | diff --git a/docs/IM-群聊业务表设计.sql b/docs/IM-群聊业务表设计.sql index b49023b..5041687 100644 --- a/docs/IM-群聊业务表设计.sql +++ b/docs/IM-群聊业务表设计.sql @@ -1,14 +1,12 @@ -- IM group business tables design. -- Scope: chapter 6 group chat business truth tables. --- Database: tenant database, default tinode. +-- 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. -CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; - -- 1. Group profile and governance truth. -- Topic/Subscription remains the communication projection; this table is the product business truth. -CREATE TABLE IF NOT EXISTS tinode.im_group_settings ( +CREATE TABLE IF NOT EXISTS im_group_settings ( topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL DEFAULT '', @@ -36,7 +34,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_settings ( -- 2. Group members and role truth. -- Role changes must update this table and the Tinode Subscription ACL in the same transaction. -CREATE TABLE IF NOT EXISTS tinode.im_group_members ( +CREATE TABLE IF NOT EXISTS im_group_members ( id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, @@ -63,7 +61,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_members ( -- 3. Direct invitations. -- If join_approval is off, accepting an invitation can directly create im_group_members + Subscription. -- If join_approval is on, accepting may create a pending im_group_join_requests record. -CREATE TABLE IF NOT EXISTS tinode.im_group_invitations ( +CREATE TABLE IF NOT EXISTS im_group_invitations ( id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, @@ -87,7 +85,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_invitations ( -- 4. Join requests. -- Pending users must not have a readable Subscription before approval. -CREATE TABLE IF NOT EXISTS tinode.im_group_join_requests ( +CREATE TABLE IF NOT EXISTS im_group_join_requests ( id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, @@ -114,7 +112,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_join_requests ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 5. Group announcements. -CREATE TABLE IF NOT EXISTS tinode.im_group_announcements ( +CREATE TABLE IF NOT EXISTS im_group_announcements ( id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, @@ -136,7 +134,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_announcements ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 6. Announcement read receipts. -CREATE TABLE IF NOT EXISTS tinode.im_group_announcement_reads ( +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, @@ -149,7 +147,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_announcement_reads ( -- 7. Current member mute state. -- Historical changes are stored in im_group_admin_logs. -CREATE TABLE IF NOT EXISTS tinode.im_group_mutes ( +CREATE TABLE IF NOT EXISTS im_group_mutes ( topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL, @@ -169,7 +167,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_mutes ( -- 8. Group QR codes / invite tokens. -- qr_token should remain opaque to clients. Store only token hash. -CREATE TABLE IF NOT EXISTS tinode.im_group_qrcodes ( +CREATE TABLE IF NOT EXISTS im_group_qrcodes ( id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL, @@ -189,7 +187,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_qrcodes ( -- 9. Group file directory. -- File binary metadata still lives in the existing file table/storage. This is the group-visible index. -CREATE TABLE IF NOT EXISTS tinode.im_group_files ( +CREATE TABLE IF NOT EXISTS im_group_files ( id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, @@ -218,7 +216,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_files ( -- 10. Group operation audit logs. -- Store role changes, governance changes, mutes, announcements, owner transfer, dissolve, file delete, etc. -CREATE TABLE IF NOT EXISTS tinode.im_group_admin_logs ( +CREATE TABLE IF NOT EXISTS im_group_admin_logs ( id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, @@ -241,7 +239,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_admin_logs ( -- 11. Group operation idempotency. -- This can later be replaced by a global HTTP idempotency table. -CREATE TABLE IF NOT EXISTS tinode.im_group_idempotency ( +CREATE TABLE IF NOT EXISTS im_group_idempotency ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, method VARCHAR(8) NOT NULL, @@ -260,7 +258,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_idempotency ( -- 12. Group outbox events. -- Transactional bridge for group.membership.updated, group.join_request.created, -- group.announcement.updated, group.role.updated, group.mute.updated, group.dissolved, etc. -CREATE TABLE IF NOT EXISTS tinode.im_group_events_outbox ( +CREATE TABLE IF NOT EXISTS im_group_events_outbox ( id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, diff --git a/docs/IM-需求实现情况审核报告.md b/docs/IM-需求实现情况审核报告.md new file mode 100644 index 0000000..a6383bf --- /dev/null +++ b/docs/IM-需求实现情况审核报告.md @@ -0,0 +1,434 @@ +# IM 产品需求实现情况审核报告 + +> 审核日期:2026-08-17 +> 审核对象:当前仓库代码、数据库脚本及运行配置 +> 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md` +> 多租户依据:`IM-独立库多租户改造方案.md` +> 明确排除:已废弃的 `IM-服务端多租户改造方案.md` + +## 1. 审核说明 + +本报告以代码和数据库脚本的实际状态为准,不直接沿用前后端对接文档中的“现状/目标”状态。对接文档中的状态是设计阶段快照,部分“新增”接口目前已经实现,部分已注册接口仍缺少业务闭环。 + +判定口径: + +| 状态 | 含义 | +| --- | --- | +| 已实现 | 主流程、存储和必要权限校验已落地,可进入联调 | +| 部分实现 | 已有接口或数据模型,但缺少关键策略、通知、推送、事务或异常闭环 | +| 阻断缺陷 | 代码已存在,但当前实现会导致主流程失败、权限绕过或数据不一致 | +| 未实现 | 未找到对应路由、业务逻辑或持久化实现 | + +本次为代码审核并辅以 Go 单元测试和真实 MySQL 适配器集成测试,不包含多节点 WebSocket、移动端、短信通道和真实 FCM 凭据的端到端联调。 + +## 2. 总体结论 + +当前版本已经不是初始 Tinode 的简单 topic 模型。好友关系、群业务扩展表、认证安全、收藏搜索、工作台、会议、日报月报、反馈举报等模块均已出现较完整的 HTTP 路由和存储接口,产品功能骨架已经形成。 + +首轮审核发现的 P0 和本报告第 4 节的 6 项 P1 已于 2026-08-17 完成代码修复。项目仍不应按“全部完成”交付,以下部署和 P2 闭环仍需处理: + +1. FCM 已接通产品 Token 表,但真实环境凭据和移动端联调仍待部署完成。 +2. 真实短信、多节点 WebSocket、通话竞态和后台处置工作流仍未闭环。 +3. 青少年模式、文件安全、群文件和会话设备管理等 P2 能力仍待开发。 + +### 2.1 分章完成度 + +| 章节 | 模块 | 当前判断 | 摘要 | +| --- | --- | --- | --- | +| 0 | 公共协议与基础约定 | 已实现主流程 | 统一响应、公共头、消息协议、Bearer 认证和全局幂等已落地 | +| 1 | 租户、认证与安全 | 部分实现 | 租户解析、验证码、密码重置、实名提交、安全挑战已实现;真实短信、会话管理等缺失 | +| 2 | 首页、会话与消息基础 | 部分实现 | 会话、回执、撤回、编辑、能力开关、实时事件和系统通知已落地;稳定游标等仍待增强 | +| 3 | 单聊与好友关系 | 部分实现 | 好友、黑名单、隐私策略、关系事件和通知已落地;历史文件/通话组合规则仍待验收 | +| 4 | 消息能力 | 部分实现 | @、媒体、转发、收藏及协议类型兼容已有;@通知和贴纸等缺失 | +| 5 | 联系人与关系管理 | 部分实现 | 基础功能、全局幂等和实时事件已落地;审计和二维码风控仍需增强 | +| 6 | 群聊 | P0 已修复,仍部分实现 | 初始化、邀请、申请、禁言、事务和 Outbox 已修复;群文件、解散等仍缺失 | +| 7 | 收藏、搜索与文件 | 部分实现 | 收藏、上下文、媒体搜索、文件 ACL/Range/ETag 已有;群文件、安全扫描等缺失 | +| 8 | 音视频通话 | 部分实现 | Tinode 通话事件和记录已存在;离线呼叫、跨设备竞态、群通话等未闭环 | +| 9 | 工作台与办公协同 | 部分实现 | 工作台、公开租户编码、组织查询、会议通知与报表已实现;权限仍需增强 | +| 10 | 设置与设备 | 部分实现 | 资料、隐私及主要策略执行、青少年模式、版本、Push Token 已有;设备会话与青少年统一策略仍待增强 | +| 11 | 安全、举报与反馈 | 部分实现 | URL 检查、挑战、举报和反馈提交已有;风险令牌消费、处置工作流和安全通知缺失 | + +## 3. 发布阻断问题 + +> 修复状态更新(2026-08-17):本节列出的 7 项 P0 代码问题均已处理。FCM 代码链路已经接通,部署环境仍必须提供真实 Firebase 服务账号凭据。 + +### P0-01 消息头协议与对接文档不一致(已修复) + +对接文档约定消息头使用: + +```json +{ + "x-im-schema": 1, + "x-im-type": "text" +} +``` + +修复前的校验在 `server/store/store.go` 中存在两处不兼容: + +- `x-im-schema` 被要求为字符串,文档示例中的数字 `1` 会被判为 malformed。 +- 消息类型主要校验 `head["type"]`,而不是协议约定的 `head["x-im-type"]`。 +- 服务端允许的 `type` 值与文档的 `voice`、`contact_card`、`forward_bundle`、`system_event` 等枚举不一致。 +- `server/topic.go:1050` 仅通过 `head["type"] == "system"` 防止客户端伪造系统消息,无法覆盖 `x-im-type=system_event`。 + +修复结果:现已接受数字或字符串形式的 schema 1,以 `x-im-type` 为产品协议字段并兼容旧 `type`;增加类型别名兼容、双字段冲突校验和 `system_event` 防伪测试。 + +### P0-02 群业务表未按租户业务库自动初始化(已修复) + +修复前群表脚本存在以下问题: + +- SQL 在第 7 行硬编码 `CREATE DATABASE tinode`,所有表也写死在 `tinode.im_group_*`。 +- `scripts/check_group_business_tables.go:44` 写死 `TABLE_SCHEMA='tinode'`。 +- `server/db/mysql/adapter.go` 的业务库创建流程没有创建这些群业务表。 + +修复结果:12 张群表已进入 MySQL 统一初始化;服务启动和首次打开独立租户库时会执行幂等建表;SQL 与检查脚本改为使用 DSN 当前选中的租户库,不再硬编码 `tinode`。 + +### P0-03 群邀请会把非成员误判为已入群(已修复) + +原逻辑只判断 `store.Subs.Get(...)` 的 `err == nil`,因此会把 `(nil, nil)` 的非成员判为已入群。 + +修复结果:邀请改为 MySQL 原子操作,在事务内判断有效 Subscription、恢复删除订阅、写邀请和成员记录、更新人数并写 Outbox;已补充非成员邀请回归测试。 + +### P0-04 入群申请主流程和状态一致性有问题(已修复) + +原入群申请入口复用了 `requireIMGroupMember`,群外用户无法提交申请;审批状态和成员写入也不在同一事务。 + +修复结果:POST 入群申请只校验登录用户、群状态及当前非成员状态;审批在一个事务内锁定申请,先完成 Subscription/成员写入,再更新申请状态和 Outbox,任何一步失败都会回滚。 + +### P0-05 群禁言未接入消息发布权限(已修复) + +原 WebSocket 发布路径没有查询成员禁言和全员禁言状态。 + +修复结果:消息持久化前调用群发言策略,覆盖成员禁言、全员禁言、禁言过期、管理员豁免和 Root 系统消息;已补充被禁言成员发布返回 403 的测试。 + +### P0-06 产品 Push Token 与 FCM 数据源断开(代码已修复) + +- 产品 Push Token 接口写入 `im_device_push_tokens`,原 FCM 只读取旧 `im_devices`。 +- 仓库默认配置没有内置 Firebase 凭据。 + +修复结果:FCM 现优先读取产品 Token 表,并保留旧设备表回退;支持通过 `IM_FCM_CREDENTIALS_FILE` 或 `IM_FCM_CREDENTIALS_JSON` 注入凭据并自动启用。真实 Android/iOS 推送仍需部署方提供有效凭据完成联调。 + +### P0-07 群写操作缺少事务和可靠事件机制(已修复) + +原建群、成员变更、审批、退群、角色变更和群主转让分别修改 Topic、Subscription 与群扩展表,并存在忽略错误的情况。 + +修复结果:上述关键写操作已收口到租户 MySQL 事务,角色 ACL 与业务角色同步更新;同一事务写入 `im_group_events_outbox`,提交后通过 Hub 向在线 Topic 派发,派发失败保留 pending 状态并在后续群请求重试。其余双写路径不再忽略错误,并提供回滚补偿。 + +## 4. 跨模块公共问题 + +> 修复状态更新(2026-08-17):本节 6 项 P1 代码问题均已处理。 + +### P1-01 HTTP 公共请求头未统一执行(已修复) + +对接文档要求产品 HTTP 请求携带 `X-Tinode-APIKey`、`X-IM-Schema` 和 `X-Request-ID`。修复前各 Handler 主要在 `authenticateIMHTTPBearer` 中校验 Bearer Token: + +- `/v1/im` 认证接口未统一校验 API Key。 +- 未找到统一的 HTTP `X-IM-Schema` 版本校验。 +- `X-Request-ID` 仅回显,缺失时不会拒绝或生成服务端 Request ID。 + +修复结果:`/v1/im/` 已统一经过协议中间件,强制校验 Header 中的 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;`X-Request-ID` 缺失时服务端生成并在响应中回传。 + +### P1-02 幂等只检查 Key 是否存在,没有实现幂等语义(已修复) + +修复前 `requireIMIdempotencyKey` 主要检查请求头非空,没有统一持久化以下信息: + +- `tenant + uid + method + route + idempotency_key` +- 请求体哈希 +- 首次响应状态码和响应体 + +修复结果:新增租户库 `im_http_idempotency` 表和统一中间件,按用户、方法、路由和 Key 建立唯一作用域,持久化 Query/Body 哈希及首次状态码/响应体;支持完整响应重放、同 Key 不同请求冲突和处理中冲突。 + +### P1-03 HTTP 写操作缺少在线事件与 Outbox(已修复) + +修复前,消息编辑/撤回、好友申请/通过/删除/拉黑、群资料与成员变更等 HTTP 操作大多只修改数据库并返回 HTTP 响应,没有统一广播到在线会话,也没有可靠 Outbox 供重试。 + +修复结果:新增 `im_business_events_outbox`,通过 Hub 向 Topic 或用户其他设备发布 `im` 事件,队列满或 Hub 不可用时保留 pending 以便重试。已覆盖消息编辑/撤回、好友关系、群业务、会议和安全事件;消息、好友申请/审批、群与会议的关键写路径在业务事务内写 Outbox,其他可重复关系写由全局幂等机制保证失败后可补写。 + +### P1-04 系统通知只有查询能力,缺少业务生产者(已修复) + +修复前,系统通知表、列表和已读接口已经存在,但好友、安全、群聊、会议等模块没有完整生产链路。 + +修复结果:好友申请/处理/删除、群成员与群设置、会议变更/提醒/回应和安全确认均已生产用户系统通知,通知 `event_id` 可重复执行且不会重复入箱。 + +### P1-05 隐私配置已存储但未全面执行(已修复) + +修复前,资料可见性、加好友方式、最后在线时间、已读回执等配置已经有表和接口,但主要读写路径没有执行这些策略。 + +修复结果:搜索与好友申请已执行 `add_me_methods`,用户资料执行 `profile_visibility`,HTTP 回执列表执行 `read_receipts`,Tinode P2P `get.desc/get.sub` 输出执行 `last_seen_visibility`;非法隐私枚举也会在存储入口被拒绝。 + +### P1-06 返回了内部租户 ID(已修复) + +修复前工作台数据把数字 `TenantID` 格式化后放入 `tenant_code` 返回。对接约定要求客户端只使用公开租户编码,不感知内部 `tenant_id`。 + +修复结果:工作台会根据当前 `TenantID` 查询租户注册表,`tenant_code` 仅返回公开 `code`,不再暴露内部数字 ID。 + +## 5. 按需求章节审核 + +### 第 1 章:租户、认证与安全 + +**已实现** + +- 租户解析、状态检查和 WebSocket `hi` 阶段租户绑定。 +- Basic/Token 登录和 Token 中的租户、`auth_version` 校验。 +- 注册验证码创建/校验、注册前验证消费、密码重置。 +- 独立密码设置与验证、安全挑战、实名提交与状态查询。 +- 用户安全状态变化后的在线 Session 驱逐能力。 + +**问题与缺失** + +- 验证码目前主要完成生成、哈希和记录,未接入可验证的真实短信发送通道。 +- 限流以租户、场景、手机号和重发窗口为主,缺少 IP、设备、账号等组合风控。 +- 未找到完整的登录失败次数、账号临时锁定和解锁闭环。 +- 实名认证只有用户提交和状态查询,缺少审核/第三方认证结果回写流程。 +- 缺少 Refresh Token、登录设备列表、单设备下线、全部设备下线等会话管理 API。 +- 缺少 API Key 轮换、吊销和灰度兼容机制。 + +### 第 2 章:首页、会话与消息基础 + +**已实现** + +- Tinode `me/sub/pres` 会话基础能力。 +- Bootstrap、会话搜索、未读/已读回执、消息撤回和编辑接口。 +- 系统通知列表、未读数和已读接口。 +- 撤回/编辑能力开关、事务内 Outbox 和在线事件已开启。 +- 好友、群、会议和安全模块可向系统通知箱生产通知。 + +**问题与缺失** + +- 回执游标依赖当前成员集合和整数偏移,成员变化后不属于稳定游标。 + +### 第 3 章:单聊与好友关系 + +**已实现** + +- 好友申请、申请列表、同意、拒绝、好友列表和删除。 +- 好友备注、黑名单、关系上下文和用户资料查询。 +- WebSocket P2P 发消息前调用 `CanSendP2P`,可阻止非好友或拉黑关系发送。 +- 单聊消息、回执、文件上传和历史消息基础能力。 +- 好友申请、处理、删除、拉黑和备注会生成可重试关系事件,需要提醒对端的操作同时生成系统通知。 +- 搜索、好友申请、资料、已读回执和最后在线时间已执行隐私设置。 + +**问题与缺失** + +- 删除好友或拉黑后的历史消息、文件下载、通话权限缺少统一规则和验收测试。 + +### 第 4 章:消息能力 + +**已实现** + +- @成员和 `@all` 解析,`@all` 有管理员权限限制。 +- 文本、媒体、文件等基础消息与附件上传。 +- 消息转发、收藏和历史上下文查询。 +- 通话消息会写入 `x-im-type=call`。 + +**问题与缺失** + +- @消息没有形成专门通知、Push 优先级和频控策略。 +- 未找到贴纸包/表情商店相关数据模型与接口。 +- 媒体上传进度、失败恢复主要依赖客户端,服务端缺少分片/断点上传状态机。 +- 消息 `client_mid` 的幂等写入和 topic seq 增长不是完整事务,并发重复请求可能产生序号空洞或冲突错误。 + +### 第 5 章:联系人和关系管理 + +**已实现** + +- 用户搜索、好友、申请、黑名单、关系上下文等核心接口已经注册。 +- 关系表和 P2P 发送权限已纳入 MySQL 租户业务存储。 +- 关系写操作已纳入全局幂等、系统通知和实时事件链路。 + +**问题与缺失** + +- 手机号/通讯录匹配的授权、脱敏和隐私规则未形成完整闭环。 +- 关系独立审计记录仍需补充。 +- 二维码关系入口主要依赖签名令牌,缺少服务端可吊销记录、使用次数和风险控制。 + +### 第 6 章:群聊 + +**已实现** + +- 群创建、资料、成员、成员资料、邀请、入群申请、公告、角色、转让群主、退群、禁言、二维码等路由已增加。 +- 已持久化群设置、群成员、邀请、入群申请、公告、公告已读和禁言数据。 +- `docs/IM-群聊业务表设计.sql` 设计了 12 张群业务扩展表。 + +**已修复的阻断项与剩余缺失** + +- 群业务表自动初始化已修复,见 P0-02。 +- 群邀请误判非成员已修复,见 P0-03。 +- 群外申请和审批一致性已修复,见 P0-04。 +- WebSocket 禁言校验已修复,见 P0-05。 +- 建群、成员、角色和群主变更事务及 Outbox 已落地,见 P0-07。 +- `im_group_qrcodes`、`im_group_files`、`im_group_admin_logs` 目前仍主要是表设计;`im_group_idempotency` 和 `im_group_events_outbox` 已有生产代码。 +- 未找到群解散接口和完整状态清理流程。 +- 未找到群文件列表、分类、搜索和文件消息自动索引能力。 +- 群二维码主要使用 HMAC Token,没有持久化,无法单码吊销、限制使用次数或查看使用记录。 +- 群公告、成员、角色、禁言等变更已接入 Outbox 与系统通知,真实多端与离线 Push 仍需联调验收。 + +### 第 7 章:收藏、搜索与文件 + +**已实现** + +- 收藏新增、列表、详情、删除和转发。 +- 消息搜索、上下文定位和媒体消息筛选。 +- 文件资源访问会校验用户权限,响应支持 ETag;本地存储通过 `ServeContent` 支持 Range,S3 支持预签名 URL。 + +**问题与缺失** + +- 未接入病毒扫描、恶意文件识别、图片/视频内容安全和隔离状态。 +- 群文件索引表没有写入及查询实现。 +- 搜索和列表游标的稳定性、权限快照和数据删除后的结果一致性需要专项测试。 +- 文件下载审计、敏感文件水印和生命周期清理未实现。 + +### 第 8 章:音视频通话 + +**已实现** + +- Tinode 通话邀请、接听、挂断等实时事件基础能力。 +- 通话记录创建、接听、结束和列表接口。 +- P2P 消息入口的好友关系校验可覆盖在线通话邀请的基础关系限制。 + +**问题与缺失** + +- FCM 代码已使用产品 Token 数据源,但离线来电仍需真实凭据和移动端联调验收。 +- 多设备同时接听缺少服务端唯一接听者仲裁和状态序列控制。 +- 通话状态写入、实时事件和 Push 之间没有幂等/Outbox 保证。 +- 未实现通话质量指标、失败原因标准化和质量上报。 +- 未找到群语音/群视频通话的完整参与者状态模型。 +- 好友、黑名单、青少年模式等策略没有通过统一服务覆盖所有通话入口。 + +### 第 9 章:工作台与办公协同 + +**已实现** + +- 工作台聚合接口、组织树和成员搜索。 +- 会议创建、修改、取消、提醒、参会响应和查询。 +- 会议变更与参会响应在同一事务内写入 Outbox 和系统通知。 +- 工作台 `tenant_code` 返回注册表的公开租户编码。 +- 日报/月报草稿、提交、列表、统计和提醒。 +- 邮箱配置查询和问题提交。 + +**问题与缺失** + +- 工作台功能项与权限没有完全由服务端动态下发和裁剪。 +- 组织、会议和报表的数据范围/角色权限需要更严格的统一校验和跨租户测试。 +- 会议提醒已有实时事件和系统通知;报表审批/提交结果、角标和真实 Push 仍需闭环。 +- 邮箱提交更接近收件记录,缺少分派、处理、回复、关闭等工作流。 + +### 第 10 章:设置、隐私与设备 + +**已实现** + +- 个人资料读写、隐私设置读写。 +- 加好友方式、资料可见性、最后在线时间和已读回执已在主要业务入口执行。 +- 青少年模式启用、关闭和查询。 +- 最新版本查询和设备 Push Token 注册接口。 + +**问题与缺失** + +- `GET /me` 的实名状态和青少年模式字段存在硬编码默认值,与独立查询接口的数据可能不一致。 +- 缺少登录设备/会话列表、设备改名、单设备退出、全端退出等完整设备管理。 +- 青少年模式目前以设置存储为主,夜间限制、内容过滤、通话/加好友限制没有统一策略执行层。 +- Push Token 注册与 FCM 发送数据源已统一,真实 Firebase 凭据和移动端仍待联调。 +- 版本接口缺少灰度发布、渠道、强更分组及回滚策略。 + +### 第 11 章:安全、举报与反馈 + +**已实现** + +- URL 安全检查和基础规则判定。 +- 风险挑战继续/取消流程及风险动作令牌签发。 +- 举报原因列表、举报提交、反馈配置和反馈提交。 + +**问题与缺失** + +- `risk_action_token` 签发后,未找到原始高风险业务操作对该令牌进行统一消费和一次性校验的链路。 +- URL 检查主要是基础规则,缺少信誉库、重定向链分析和异步复检。 +- 举报和反馈以接收为主,缺少审核、分派、处置、申诉、回复和 SLA 状态流转。 +- 风险确认已写入用户可见的系统安全通知,其他安全操作仍需按场景补齐。 +- 举报证据文件没有明确接入专门 ACL、安全扫描和保留/删除策略。 + +## 6. 数据库与迁移审核 + +### 已具备 + +- 核心 Tinode 表已统一使用 `im_` 前缀。 +- 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。 +- 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。 +- `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。 + +### 需要修正 + +1. 建立唯一 schema 版本和迁移入口,禁止“应用启动建部分表 + 手工 SQL 建另一部分表”长期并存。 +2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。 +3. 新租户建库和存量租户升级必须执行同一套 migration。 +4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。 +5. 对已设计但未使用的群表明确去留,避免表结构被误认为功能已经完成。 +6. 补充索引、唯一键和外键/逻辑引用的一致性检查,尤其是邀请、申请、成员和幂等键。 + +## 7. 测试与验证结果 + +### 本次执行 + +```bash +go test -tags mysql ./server/db/mysql ./server/store ./server/push/fcm ./server -count=1 +``` + +结果:全部通过。覆盖 HTTP 协议头、幂等响应重放/冲突、消息协议、隐私策略、群业务、FCM Token 数据源和 MySQL 建表语句。 + +```bash +go test -tags mysql ./server/db/mysql/tests -count=1 +``` + +结果:使用 `root/root@127.0.0.1:3306` 和临时数据库完整通过;包含租户库创建、群表、P1 基础设施表、无 `tenant_id` 约束和多租户隔离测试。测试后已恢复 `test.conf` 并删除临时数据库。 + +### 测试缺口 + +- 缺少 HTTP 写入后 WebSocket 多端同步的集成测试。 +- 缺少真实 FCM 凭据和移动端设备的离线推送测试。 +- 隐私策略的基础分支已有测试,但黑名单、青少年模式、文件和通话的组合权限仍缺专项矩阵。 +- 幂等重放和同 Key 不同 Body 已有单测,多进程高并发压测仍未执行。 + +## 8. 文档一致性问题 + +1. `IM-服务端多租户改造方案.md` 已明确废弃,本报告未把它作为实现依据。 +2. `IM-系统设计文档.md` 中仍有共享业务库加 `tenant_id` 的描述,与当前独立租户业务库方向不一致,需要更新或标记历史版本。 +3. `IM-前后端对接文档.md` 的接口状态是设计阶段快照,已有多项“新增”路由落地,应按本报告和实际联调结果刷新。 +4. `IM-未实现后续增强项.md` 已于 2026-08-17 按最新 P0/P1 修复状态同步:已完成项已移入各章基线,待办表仅保留未实现或待环境联调的增强项。 +5. 群表设计文档与独立库多租户方案冲突,脚本中的 `tinode` 库名必须移除。 + +## 9. 建议开发顺序 + +### 第一阶段:解除主流程阻断(已完成) + +1. 统一消息协议字段、类型枚举和兼容策略。 +2. 把群表纳入租户库 migration,并完成存量租户升级。 +3. 修复群邀请、群外申请、审批一致性和禁言绕过。 +4. 为建群、成员变更、群主转让建立事务和明确错误处理。 +5. 统一 Push Token 数据源并启用 FCM 测试环境。 + +### 第二阶段:完成生产闭环(P1 代码已完成) + +1. 建立统一 HTTP Middleware:API Key、Schema、Request ID、Bearer、限流。 +2. 建立统一幂等存储和响应重放机制。 +3. 落地 Outbox,补齐关系、群、消息编辑/撤回、会议等实时事件。 +4. 建立系统通知生产服务,并与 Push、角标联动。 +5. 建立统一隐私/关系/青少年模式 Policy Service。 + +### 第三阶段:补齐增强能力 + +1. 群文件、群解散、二维码管理、管理员日志。 +2. 多设备会话管理、Refresh Token、API Key 生命周期。 +3. 通话多设备仲裁、质量指标及群通话。 +4. 举报、反馈、实名和邮箱的后台处置工作流。 +5. 文件安全扫描、版本灰度和运营配置能力。 + +## 10. 建议验收门槛 + +在声明产品需求“已完成”前,至少满足: + +- 新建租户无需手工 SQL 即可完成全部模块初始化。 +- 文档示例消息可直接发送、存储、搜索、转发并被旧客户端兼容处理。 +- 所有写接口具备可验证的幂等语义。 +- HTTP 状态变更能可靠同步到 WebSocket 在线端,并能通过 Outbox 重试。 +- FCM 使用产品注册的 Token 完成真实离线推送。 +- 群邀请、申请、禁言、退群、转让和解散通过真实 MySQL 集成测试。 +- 好友、黑名单、隐私、青少年模式在消息、文件、资料、通话入口保持一致。 +- `go test ./...` 能完整编译并通过;关键链路另有端到端测试。 diff --git a/docs/IM-多租户第5至12章代码改造与测试方案.md b/docs/obsolete/IM-多租户第5至12章代码改造与测试方案.md similarity index 100% rename from docs/IM-多租户第5至12章代码改造与测试方案.md rename to docs/obsolete/IM-多租户第5至12章代码改造与测试方案.md diff --git a/docs/IM-服务端多租户改造方案.md b/docs/obsolete/IM-服务端多租户改造方案.md similarity index 99% rename from docs/IM-服务端多租户改造方案.md rename to docs/obsolete/IM-服务端多租户改造方案.md index 2c37aa4..7b43d4b 100644 --- a/docs/IM-服务端多租户改造方案.md +++ b/docs/obsolete/IM-服务端多租户改造方案.md @@ -1,13 +1,13 @@ # IM 服务端多租户改造方案 -> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](./IM-独立库多租户改造方案.md)。** +> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](../IM-独立库多租户改造方案.md)。** > > 新方案下,业务库中的 `users`、`auth`、`topics`、`messages`、`fileuploads` 和后续产品业务表不再增加 `tenant_id` 字段;`TenantID` 仍保留在 Session、Token、Hub、缓存、Push、文件授权、插件上下文和 Store 选库流程中,用于定位租户业务库和运行时隔离。 > 编写日期:2026-08-10 > 适用项目:`im_server` 当前服务端源码 -> 需求背景:[IM 产品原型需求文档](./IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同” -> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md) +> 需求背景:[IM 产品原型需求文档](../IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同” +> 关联文档:[IM 产品需求服务端改造分析报告](../IM-产品需求服务端改造分析报告.md) > 实施状态:第一阶段租户识别已落地。MySQL Adapter schema 117 创建 `im_tenant`、`im_tenant_config` 和默认租户;首次 `hi` 使用企业码解析并固定 Session TenantID。核心业务表的 `tenant_id`、租户化认证和路由隔离尚未完成。 diff --git a/docs/sql/mysql-multitenant-tenant-id-review.sql b/docs/sql/mysql-multitenant-tenant-id-review.sql index 736ef05..656b966 100644 --- a/docs/sql/mysql-multitenant-tenant-id-review.sql +++ b/docs/sql/mysql-multitenant-tenant-id-review.sql @@ -68,75 +68,75 @@ DROP PROCEDURE assert_default_tenant; -- 2. Add nullable tenant_id columns first. -- --------------------------------------------------------------------------- -ALTER TABLE users +ALTER TABLE im_users ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE usertags +ALTER TABLE im_usertags ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE devices +ALTER TABLE im_devices ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE auth +ALTER TABLE im_auth ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE topics +ALTER TABLE im_topics ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE topictags +ALTER TABLE im_topictags ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE subscriptions +ALTER TABLE im_subscriptions ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE messages +ALTER TABLE im_messages ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE dellog +ALTER TABLE im_dellog ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE credentials +ALTER TABLE im_credentials ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE fileuploads +ALTER TABLE im_fileuploads ADD COLUMN tenant_id BIGINT NULL AFTER id; -ALTER TABLE filemsglinks +ALTER TABLE im_filemsglinks ADD COLUMN tenant_id BIGINT NULL AFTER id; -- --------------------------------------------------------------------------- -- 3. Backfill all existing rows into the default tenant. -- --------------------------------------------------------------------------- -UPDATE users SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE usertags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE devices SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE auth SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE topics SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE topictags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE subscriptions SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE messages SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE dellog SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE credentials SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE fileuploads SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -UPDATE filemsglinks SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_users SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_usertags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_devices SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_auth SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_topics SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_topictags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_subscriptions SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_messages SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_dellog SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_credentials SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_fileuploads SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; +UPDATE im_filemsglinks SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL; -- Validate the backfill before adding NOT NULL constraints. DELIMITER $$ CREATE PROCEDURE assert_no_null_tenant_id() BEGIN - IF EXISTS (SELECT 1 FROM users WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM usertags WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM devices WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM auth WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM topics WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM topictags WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM subscriptions WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM messages WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM dellog WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM credentials WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM fileuploads WHERE tenant_id IS NULL LIMIT 1) - OR EXISTS (SELECT 1 FROM filemsglinks WHERE tenant_id IS NULL LIMIT 1) + IF EXISTS (SELECT 1 FROM im_users WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_usertags WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_devices WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_auth WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_topics WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_topictags WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_subscriptions WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_messages WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_dellog WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_credentials WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_fileuploads WHERE tenant_id IS NULL LIMIT 1) + OR EXISTS (SELECT 1 FROM im_filemsglinks WHERE tenant_id IS NULL LIMIT 1) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'tenant_id backfill is incomplete'; @@ -152,18 +152,18 @@ DROP PROCEDURE assert_no_null_tenant_id; -- No DEFAULT is retained: new code must always provide a tenant explicitly. -- --------------------------------------------------------------------------- -ALTER TABLE users MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE usertags MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE devices MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE auth MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE topics MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE topictags MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE subscriptions MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE messages MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE dellog MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE credentials MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE fileuploads MODIFY COLUMN tenant_id BIGINT NOT NULL; -ALTER TABLE filemsglinks MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_users MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_usertags MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_devices MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_auth MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_topics MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_topictags MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_subscriptions MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_messages MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_dellog MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_credentials MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_fileuploads MODIFY COLUMN tenant_id BIGINT NOT NULL; +ALTER TABLE im_filemsglinks MODIFY COLUMN tenant_id BIGINT NOT NULL; -- --------------------------------------------------------------------------- -- 5. Drop existing single-column foreign keys. @@ -197,15 +197,15 @@ BEGIN END$$ DELIMITER ; -CALL drop_table_foreign_keys('usertags'); -CALL drop_table_foreign_keys('devices'); -CALL drop_table_foreign_keys('auth'); -CALL drop_table_foreign_keys('topictags'); -CALL drop_table_foreign_keys('subscriptions'); -CALL drop_table_foreign_keys('messages'); -CALL drop_table_foreign_keys('dellog'); -CALL drop_table_foreign_keys('credentials'); -CALL drop_table_foreign_keys('filemsglinks'); +CALL drop_table_foreign_keys('im_usertags'); +CALL drop_table_foreign_keys('im_devices'); +CALL drop_table_foreign_keys('im_auth'); +CALL drop_table_foreign_keys('im_topictags'); +CALL drop_table_foreign_keys('im_subscriptions'); +CALL drop_table_foreign_keys('im_messages'); +CALL drop_table_foreign_keys('im_dellog'); +CALL drop_table_foreign_keys('im_credentials'); +CALL drop_table_foreign_keys('im_filemsglinks'); DROP PROCEDURE drop_table_foreign_keys; @@ -214,40 +214,40 @@ DROP PROCEDURE drop_table_foreign_keys; -- Parent identity indexes are also added for composite foreign keys. -- --------------------------------------------------------------------------- -ALTER TABLE users +ALTER TABLE im_users ADD UNIQUE KEY uk_users_tenant_id_id (tenant_id, id), ADD KEY idx_users_tenant_state_stateat (tenant_id, state, stateat), ADD KEY idx_users_tenant_lastseen_updatedat (tenant_id, lastseen, updatedat); -ALTER TABLE usertags +ALTER TABLE im_usertags ADD UNIQUE KEY uk_usertags_tenant_user_tag (tenant_id, userid, tag), ADD KEY idx_usertags_tenant_tag (tenant_id, tag), DROP INDEX usertags_userid_tag; -ALTER TABLE devices +ALTER TABLE im_devices ADD UNIQUE KEY uk_devices_tenant_hash (tenant_id, hash), ADD KEY idx_devices_tenant_user (tenant_id, userid), DROP INDEX devices_hash; -ALTER TABLE auth +ALTER TABLE im_auth ADD UNIQUE KEY uk_auth_tenant_user_scheme (tenant_id, userid, scheme), ADD UNIQUE KEY uk_auth_tenant_uname (tenant_id, uname), DROP INDEX auth_userid_scheme, DROP INDEX auth_uname; -ALTER TABLE topics +ALTER TABLE im_topics ADD UNIQUE KEY uk_topics_tenant_name (tenant_id, name), ADD KEY idx_topics_tenant_owner (tenant_id, owner), ADD KEY idx_topics_tenant_state_stateat (tenant_id, state, stateat), ADD KEY idx_topics_tenant_name_state_seqid (tenant_id, name, state, seqid), DROP INDEX topics_name; -ALTER TABLE topictags +ALTER TABLE im_topictags ADD UNIQUE KEY uk_topictags_tenant_topic_tag (tenant_id, topic, tag), ADD KEY idx_topictags_tenant_tag (tenant_id, tag), DROP INDEX topictags_topic_tag; -ALTER TABLE subscriptions +ALTER TABLE im_subscriptions ADD UNIQUE KEY uk_subscriptions_tenant_topic_user (tenant_id, topic, userid), ADD KEY idx_subscriptions_tenant_user_topic_deleted (tenant_id, userid, topic, deletedat), @@ -255,30 +255,30 @@ ALTER TABLE subscriptions (tenant_id, topic, deletedat), DROP INDEX subscriptions_topic_userid; -ALTER TABLE messages +ALTER TABLE im_messages ADD UNIQUE KEY uk_messages_tenant_id (tenant_id, id), ADD UNIQUE KEY uk_messages_tenant_topic_seqid (tenant_id, topic, seqid), DROP INDEX messages_topic_seqid; -ALTER TABLE dellog +ALTER TABLE im_dellog ADD KEY idx_dellog_tenant_topic_delid_user (tenant_id, topic, delid, deletedfor), ADD KEY idx_dellog_tenant_topic_user_low_hi (tenant_id, topic, deletedfor, low, hi), ADD KEY idx_dellog_tenant_deletedfor (tenant_id, deletedfor); -ALTER TABLE credentials +ALTER TABLE im_credentials ADD UNIQUE KEY uk_credentials_tenant_synthetic (tenant_id, synthetic), ADD KEY idx_credentials_tenant_user_method (tenant_id, userid, method, deletedat), DROP INDEX credentials_uniqueness; -ALTER TABLE fileuploads +ALTER TABLE im_fileuploads ADD UNIQUE KEY uk_fileuploads_tenant_id (tenant_id, id), ADD KEY idx_fileuploads_tenant_status_updated (tenant_id, status, updatedat); -ALTER TABLE filemsglinks +ALTER TABLE im_filemsglinks ADD KEY idx_filemsglinks_tenant_file (tenant_id, fileid), ADD KEY idx_filemsglinks_tenant_message (tenant_id, msgid), ADD KEY idx_filemsglinks_tenant_topic (tenant_id, topic), @@ -295,32 +295,32 @@ ALTER TABLE filemsglinks -- --------------------------------------------------------------------------- SELECT 'user_tags_without_same_tenant_user' AS check_name, COUNT(*) AS invalid_count -FROM usertags child -LEFT JOIN users parent +FROM im_usertags child +LEFT JOIN im_users parent ON parent.tenant_id = child.tenant_id AND parent.id = child.userid WHERE parent.id IS NULL UNION ALL SELECT 'subscriptions_without_same_tenant_user', COUNT(*) -FROM subscriptions child -LEFT JOIN users parent +FROM im_subscriptions child +LEFT JOIN im_users parent ON parent.tenant_id = child.tenant_id AND parent.id = child.userid WHERE parent.id IS NULL UNION ALL SELECT 'subscriptions_without_same_tenant_topic', COUNT(*) -FROM subscriptions child -LEFT JOIN topics parent +FROM im_subscriptions child +LEFT JOIN im_topics parent ON parent.tenant_id = child.tenant_id AND parent.name = child.topic WHERE parent.name IS NULL UNION ALL SELECT 'messages_without_same_tenant_topic', COUNT(*) -FROM messages child -LEFT JOIN topics parent +FROM im_messages child +LEFT JOIN im_topics parent ON parent.tenant_id = child.tenant_id AND parent.name = child.topic WHERE parent.name IS NULL UNION ALL SELECT 'file_links_without_same_tenant_file', COUNT(*) -FROM filemsglinks child -LEFT JOIN fileuploads parent +FROM im_filemsglinks child +LEFT JOIN im_fileuploads parent ON parent.tenant_id = child.tenant_id AND parent.id = child.fileid WHERE parent.id IS NULL; @@ -328,11 +328,11 @@ WHERE parent.id IS NULL; -- 9. Update the schema version only after all validation succeeds. -- --------------------------------------------------------------------------- -UPDATE kvmeta +UPDATE im_kvmeta SET `value` = '119' WHERE `key` = 'version'; -- Intentionally unchanged: -- * im_tenant: it is the tenant registry itself. --- * kvmeta: it stores platform-level schema metadata. Tenant-scoped cache +-- * im_kvmeta: it stores platform-level schema metadata. Tenant-scoped cache -- data must use a separate table or a mandatory tenant key namespace. diff --git a/scripts/apply_group_business_tables.go b/scripts/apply_group_business_tables.go index f38dea9..0e8df5e 100644 --- a/scripts/apply_group_business_tables.go +++ b/scripts/apply_group_business_tables.go @@ -24,6 +24,13 @@ func main() { if err = db.Ping(); err != nil { fatal(err) } + var schema string + if err = db.QueryRow("SELECT DATABASE()").Scan(&schema); err != nil { + fatal(err) + } + if strings.TrimSpace(schema) == "" { + fatal("IM_MYSQL_DSN must select a tenant database") + } raw, err := os.ReadFile("docs/IM-群聊业务表设计.sql") if err != nil { @@ -39,7 +46,7 @@ func main() { fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) } } - fmt.Println("group business tables applied") + fmt.Printf("group business tables applied to %s\n", schema) } func stripSQLLineComments(text string) string { diff --git a/scripts/check_group_business_tables.go b/scripts/check_group_business_tables.go index 9654dbe..aff9b46 100644 --- a/scripts/check_group_business_tables.go +++ b/scripts/check_group_business_tables.go @@ -24,6 +24,13 @@ func main() { if err = db.Ping(); err != nil { fatal(err) } + var schema string + if err = db.QueryRow("SELECT DATABASE()").Scan(&schema); err != nil { + fatal(err) + } + if strings.TrimSpace(schema) == "" { + fatal("IM_MYSQL_DSN must select a tenant database") + } tables := []string{ "im_group_settings", @@ -41,7 +48,7 @@ func main() { } for _, table := range tables { var count int - err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME=?`, table).Scan(&count) + err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, schema, table).Scan(&count) if err != nil { fatal(err) } diff --git a/server/db/adapter.go b/server/db/adapter.go index de7632b..e0d29d2 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -213,6 +213,7 @@ type Adapter interface { // points fail closed when the configured adapter does not support tenant resolution. type TenantAdapter interface { TenantGetByCode(code string) (*t.Tenant, error) + TenantGetByID(id t.TenantID) (*t.Tenant, error) } // TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the @@ -221,6 +222,17 @@ type TenantRoutedAdapter interface { TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error) } +// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used +// by product HTTP middleware and online business-event delivery. +type TenantHTTPInfrastructureAdapter interface { + TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) + TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error + TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error + TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error + TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error) + TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error +} + // TenantAuthAdapter is the tenant-aware authentication and credential capability. // It is separate from Adapter while the remaining persistence domains are migrated. type TenantAuthAdapter interface { @@ -304,13 +316,21 @@ type TenantBusinessAdapter interface { TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error) TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error) TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error) - TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, time.Time) (*t.GroupJoinRequest, error) + TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error) TenantGroupAnnouncementLatest(t.TenantID, string) (*t.GroupAnnouncement, error) TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error) TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error) TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error) TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error + TenantGroupCreate(t.TenantID, *t.Topic, t.Uid, any, *t.GroupSettings, []t.Uid, any, time.Time) error + TenantGroupInviteMember(t.TenantID, string, t.Uid, t.Uid, string, string, any, time.Time) (bool, error) + TenantGroupLeave(t.TenantID, string, t.Uid, time.Time) error + TenantGroupRoleUpdate(t.TenantID, string, t.Uid, t.Uid, string, t.AccessMode, time.Time) error + TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error + TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error) + TenantGroupEventListPending(t.TenantID, int, time.Time) ([]t.GroupEvent, error) + TenantGroupEventMarkDelivered(t.TenantID, string, time.Time) error TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error) TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index a446a85..46e0679 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -218,7 +218,8 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { // Actually opening the network connection. err = a.db.Ping() - if isMissingDb(err) { + databaseMissing := isMissingDb(err) + if databaseMissing { // Ignore missing database here. If we are initializing the database // missing DB is OK. err = nil @@ -238,6 +239,14 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { return errors.New("mysql adapter failed to open tenant registry database: " + err.Error()) } } + if !databaseMissing { + if err = ensureIMGroupBusinessTables(a.db); err != nil { + return errors.New("mysql adapter failed to ensure group business schema: " + err.Error()) + } + if err = ensureIMHTTPInfrastructureTables(a.db); err != nil { + return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error()) + } + } } return err } @@ -288,7 +297,7 @@ func (a *adapter) GetDbVersion() (int, error) { defer cancel() } var vers int - err := a.db.GetContext(ctx, &vers, "SELECT `value` FROM kvmeta WHERE `key`='version'") + err := a.db.GetContext(ctx, &vers, "SELECT `value` FROM im_kvmeta WHERE `key`='version'") if err != nil { if isMissingDb(err) || isMissingTable(err) || err == sql.ErrNoRows { err = errors.New("Database not initialized") @@ -307,7 +316,7 @@ func (a *adapter) updateDbVersion(v int) error { defer cancel() } a.version = -1 - if _, err := a.db.ExecContext(ctx, "UPDATE kvmeta SET `value`=? WHERE `key`='version'", v); err != nil { + if _, err := a.db.ExecContext(ctx, "UPDATE im_kvmeta SET `value`=? WHERE `key`='version'", v); err != nil { return err } return nil @@ -387,6 +396,14 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) { return &tenant, nil } +// TenantGetByID resolves a tenant registry row by internal ID. +func (a *adapter) TenantGetByID(id t.TenantID) (*t.Tenant, error) { + if !id.IsValid() { + return nil, t.ErrMalformed + } + return a.tenantByID(id) +} + func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) { if !a.tenantDBRouting { return a, a, nil @@ -418,6 +435,14 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines db.Close() return nil, nil, err } + if err = ensureIMGroupBusinessTables(db); err != nil { + db.Close() + return nil, nil, err + } + if err = ensureIMHTTPInfrastructureTables(db); err != nil { + db.Close() + return nil, nil, err + } tenantAdp := &adapter{ db: db, dsn: mysqlDefaultString(dsn, a.dsn), @@ -585,7 +610,7 @@ func (a *adapter) CreateDb(reset bool) error { } if _, err = tx.Exec( - `CREATE TABLE users( + `CREATE TABLE im_users( id BIGINT NOT NULL, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, @@ -625,12 +650,12 @@ func (a *adapter) CreateDb(reset bool) error { // Indexed user tags. if _, err = tx.Exec( - `CREATE TABLE usertags( + `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 users(id), + FOREIGN KEY(userid) REFERENCES im_users(id), INDEX usertags_tag(tag), UNIQUE INDEX usertags_userid_tag(userid, tag) )`); err != nil { @@ -639,7 +664,7 @@ func (a *adapter) CreateDb(reset bool) error { // Indexed devices. Normalized into a separate table. if _, err = tx.Exec( - `CREATE TABLE devices( + `CREATE TABLE im_devices( id INT NOT NULL AUTO_INCREMENT, userid BIGINT NOT NULL, hash CHAR(16) NOT NULL, @@ -648,7 +673,7 @@ func (a *adapter) CreateDb(reset bool) error { lastseen DATETIME NOT NULL, lang VARCHAR(8), PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), + FOREIGN KEY(userid) REFERENCES im_users(id), UNIQUE INDEX devices_hash(hash) )`); err != nil { return err @@ -656,7 +681,7 @@ func (a *adapter) CreateDb(reset bool) error { // Authentication records for the basic authentication scheme. if _, err = tx.Exec( - `CREATE TABLE auth( + `CREATE TABLE im_auth( id INT NOT NULL AUTO_INCREMENT, uname VARCHAR(32) NOT NULL, userid BIGINT NOT NULL, @@ -665,7 +690,7 @@ func (a *adapter) CreateDb(reset bool) error { secret VARCHAR(255) NOT NULL, expires DATETIME, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), + FOREIGN KEY(userid) REFERENCES im_users(id), UNIQUE INDEX auth_uname(uname), UNIQUE INDEX auth_userid_scheme(userid, scheme) )`); err != nil { @@ -674,7 +699,7 @@ func (a *adapter) CreateDb(reset bool) error { // Topics if _, err = tx.Exec( - `CREATE TABLE topics( + `CREATE TABLE im_topics( id INT NOT NULL AUTO_INCREMENT, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, @@ -708,12 +733,12 @@ func (a *adapter) CreateDb(reset bool) error { // Indexed topic tags. if _, err = tx.Exec( - `CREATE TABLE topictags( + `CREATE TABLE im_topictags( id INT NOT NULL AUTO_INCREMENT, topic CHAR(25) NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name), + FOREIGN KEY(topic) REFERENCES im_topics(name), INDEX topictags_tag(tag), UNIQUE INDEX topictags_topic_tag(topic, tag) )`); err != nil { @@ -722,7 +747,7 @@ func (a *adapter) CreateDb(reset bool) error { // Subscriptions if _, err = tx.Exec( - `CREATE TABLE subscriptions( + `CREATE TABLE im_subscriptions( id INT NOT NULL AUTO_INCREMENT, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, @@ -736,7 +761,7 @@ func (a *adapter) CreateDb(reset bool) error { modegiven CHAR(8), private JSON, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), + FOREIGN KEY(userid) REFERENCES im_users(id), UNIQUE INDEX subscriptions_topic_userid(topic, userid), INDEX subscriptions_topic(topic), INDEX subscriptions_deletedat(deletedat), @@ -747,7 +772,7 @@ func (a *adapter) CreateDb(reset bool) error { // Messages if _, err = tx.Exec( - `CREATE TABLE messages( + `CREATE TABLE im_messages( id INT NOT NULL AUTO_INCREMENT, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, @@ -763,7 +788,7 @@ func (a *adapter) CreateDb(reset bool) error { head JSON, content JSON, PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name), + FOREIGN KEY(topic) REFERENCES im_topics(name), UNIQUE INDEX messages_sender_client_mid(` + "`from`" + `, client_msg_id), UNIQUE INDEX messages_topic_seqid(topic, seqid) )`); err != nil { @@ -812,10 +837,16 @@ func (a *adapter) CreateDb(reset bool) error { if err = createIMUserProductTables(tx); err != nil { return err } + if err = ensureIMGroupBusinessTables(tx); err != nil { + return err + } + if err = ensureIMHTTPInfrastructureTables(tx); err != nil { + return err + } // Deletion log if _, err = tx.Exec( - `CREATE TABLE dellog( + `CREATE TABLE im_dellog( id INT NOT NULL AUTO_INCREMENT, topic CHAR(25) NOT NULL, deletedfor BIGINT NOT NULL DEFAULT 0, @@ -823,7 +854,7 @@ func (a *adapter) CreateDb(reset bool) error { low INT NOT NULL, hi INT NOT NULL, PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name), + FOREIGN KEY(topic) REFERENCES im_topics(name), INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor), INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi), INDEX dellog_deletedfor(deletedfor) @@ -833,7 +864,7 @@ func (a *adapter) CreateDb(reset bool) error { // User credentials if _, err = tx.Exec( - `CREATE TABLE credentials( + `CREATE TABLE im_credentials( id INT NOT NULL AUTO_INCREMENT, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, @@ -846,7 +877,7 @@ func (a *adapter) CreateDb(reset bool) error { done TINYINT NOT NULL DEFAULT 0, retries INT NOT NULL DEFAULT 0, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), + FOREIGN KEY(userid) REFERENCES im_users(id), UNIQUE credentials_uniqueness(synthetic) )`); err != nil { return err @@ -855,7 +886,7 @@ func (a *adapter) CreateDb(reset bool) error { // Records of uploaded files. // Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion. if _, err = tx.Exec( - `CREATE TABLE fileuploads( + `CREATE TABLE im_fileuploads( id BIGINT NOT NULL, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, @@ -873,7 +904,7 @@ func (a *adapter) CreateDb(reset bool) error { // Links between uploaded files and the topics, users or messages they are attached to. if _, err = tx.Exec( - `CREATE TABLE filemsglinks( + `CREATE TABLE im_filemsglinks( id INT NOT NULL AUTO_INCREMENT, createdat DATETIME(3) NOT NULL, fileid BIGINT NOT NULL, @@ -881,16 +912,16 @@ func (a *adapter) CreateDb(reset bool) error { topic CHAR(25), userid BIGINT, PRIMARY KEY(id), - FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE, - FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE, - FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE + FOREIGN KEY(fileid) REFERENCES im_fileuploads(id) ON DELETE CASCADE, + FOREIGN KEY(msgid) REFERENCES im_messages(id) ON DELETE CASCADE, + FOREIGN KEY(topic) REFERENCES im_topics(name) ON DELETE CASCADE, + FOREIGN KEY(userid) REFERENCES im_users(id) ON DELETE CASCADE )`); err != nil { return err } if _, err = tx.Exec( - `CREATE TABLE kvmeta(` + + `CREATE TABLE im_kvmeta(` + "`key` VARCHAR(64) NOT NULL," + "createdat DATETIME(3)," + "`value` TEXT," + @@ -899,7 +930,7 @@ func (a *adapter) CreateDb(reset bool) error { `)`); err != nil { return err } - if _, err = tx.Exec("INSERT INTO kvmeta(`key`, `value`) VALUES('version',?)", adpVersion); err != nil { + if _, err = tx.Exec("INSERT INTO im_kvmeta(`key`, `value`) VALUES('version',?)", adpVersion); err != nil { return err } @@ -1362,9 +1393,9 @@ func ensureTenantRoutingColumns(a *adapter) error { func upgradeIMMessageActions(a *adapter) error { statements := []string{ - "ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id", - "ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version", - "ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at", + "ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id", + "ALTER TABLE im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version", + "ALTER TABLE im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at", `CREATE TABLE im_message_revisions( id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL, @@ -1529,7 +1560,7 @@ func createIMSecurityTables(tx *sql.Tx) error { // Create system topic 'sys'. func createSystemTopic(tx *sql.Tx) error { now := t.TimeNow() - query := `INSERT INTO topics(createdat,updatedat,state,touchedat,name,access,public) + query := `INSERT INTO im_topics(createdat,updatedat,state,touchedat,name,access,public) VALUES(?,?,?,?,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')` _, err := tx.Exec(query, now, now, t.StateOK, now) return err @@ -1624,7 +1655,7 @@ func (a *adapter) UserCreate(user *t.User) error { }() decoded_uid := store.DecodeUid(user.Uid()) - if _, err = tx.Exec("INSERT INTO users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)", + if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)", decoded_uid, user.CreatedAt, user.UpdatedAt, @@ -1642,7 +1673,7 @@ func (a *adapter) UserCreate(user *t.User) error { } // Save user's tags to a separate table to make user findable. - if err = addTags(tx, "usertags", "userid", decoded_uid, user.Tags, false); err != nil { + if err = addTags(tx, "im_usertags", "userid", decoded_uid, user.Tags, false); err != nil { return err } @@ -1668,7 +1699,7 @@ func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, un defer cancel() } _, err = a.db.ExecContext(ctx, - "INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret,expires) VALUES(?,?,?,?,?,?,?)", + "INSERT INTO im_auth(tenant_id,uname,userid,scheme,authlvl,secret,expires) VALUES(?,?,?,?,?,?,?)", tenantID, unique, store.DecodeUid(uid), scheme, authLvl, secret, exp) if isDupe(err) { return t.ErrDuplicate @@ -1689,7 +1720,7 @@ func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme str if cancel != nil { defer cancel() } - _, err = a.db.ExecContext(ctx, "DELETE FROM auth WHERE tenant_id=? AND userid=? AND scheme=?", + _, err = a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE tenant_id=? AND userid=? AND scheme=?", tenantID, store.DecodeUid(uid), scheme) return err } @@ -1724,7 +1755,7 @@ func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, un if cancel != nil { defer cancel() } - resp, err := a.db.ExecContext(ctx, "UPDATE auth SET "+strings.Join(params, ",")+ + resp, err := a.db.ExecContext(ctx, "UPDATE im_auth SET "+strings.Join(params, ",")+ " WHERE tenant_id=? AND userid=? AND scheme=?", args...) if isDupe(err) { return t.ErrDuplicate @@ -1760,7 +1791,7 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str defer cancel() } err = a.db.GetContext(ctx, &record, - "SELECT uname,secret,expires,authlvl FROM auth WHERE tenant_id=? AND userid=? AND scheme=?", + "SELECT uname,secret,expires,authlvl FROM im_auth WHERE tenant_id=? AND userid=? AND scheme=?", tenantID, store.DecodeUid(uid), scheme) if err == sql.ErrNoRows { err = t.ErrNotFound @@ -1796,7 +1827,7 @@ func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) defer cancel() } err = a.db.GetContext(ctx, &record, - "SELECT userid,secret,expires,authlvl FROM auth WHERE tenant_id=? AND uname=?", tenantID, unique) + "SELECT userid,secret,expires,authlvl FROM im_auth WHERE tenant_id=? AND uname=?", tenantID, unique) if err == sql.ErrNoRows { return t.ZeroUid, auth.LevelNone, nil, expires, nil } @@ -1822,7 +1853,7 @@ func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.L defer cancel() } - if _, err := a.db.ExecContext(ctx, "INSERT INTO auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)", + if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)", unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil { if isDupe(err) { return t.ErrDuplicate @@ -1838,7 +1869,7 @@ func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error { if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, "DELETE FROM auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme) + _, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme) return err } @@ -1848,7 +1879,7 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) { if cancel != nil { defer cancel() } - res, err := a.db.ExecContext(ctx, "DELETE FROM auth WHERE userid=?", store.DecodeUid(user)) + res, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user)) if err != nil { return 0, err } @@ -1882,7 +1913,7 @@ func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.L if cancel != nil { defer cancel() } - sql := "UPDATE auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?" + sql := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?" resp, err := a.db.ExecContext(ctx, sql, args...) if isDupe(err) { return t.ErrDuplicate @@ -1910,7 +1941,7 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [ if cancel != nil { defer cancel() } - if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM auth WHERE userid=? AND scheme=?", + if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=? AND scheme=?", store.DecodeUid(uid), scheme); err != nil { if err == sql.ErrNoRows { // Nothing found - use standard error. @@ -1941,7 +1972,7 @@ func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, if cancel != nil { defer cancel() } - if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM auth WHERE uname=?", unique); err != nil { + if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=?", unique); err != nil { if err == sql.ErrNoRows { // Nothing found - clear the error err = nil @@ -1963,7 +1994,7 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) { defer cancel() } var user t.User - err := a.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id=? AND state!=?", store.DecodeUid(uid), t.StateDeleted) + err := a.db.GetContext(ctx, &user, "SELECT * FROM im_users WHERE id=? AND state!=?", store.DecodeUid(uid), t.StateDeleted) if err == nil { user.SetUid(uid) user.Public = common.FromJSON(user.Public) @@ -1993,7 +2024,7 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { if cancel != nil { defer cancel() } - q, uids, _ := sqlx.In("SELECT * FROM users WHERE id IN (?) AND state!=?", uids, t.StateDeleted) + q, uids, _ := sqlx.In("SELECT * 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 @@ -2022,7 +2053,7 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { // UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted. // TODO: report when the user is not found. func (a *adapter) UserDelete(uid t.Uid, hard bool) error { - query := "SELECT name FROM topics WHERE owner=?" + query := "SELECT name FROM im_topics WHERE owner=?" args := []any{store.DecodeUid(uid)} // In case of hard delete, delete all topics, even those which were // soft-deleted previsously. @@ -2067,7 +2098,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // Delete records of messages soft-deleted for the user in all topics. - if _, err = tx.Exec("DELETE FROM dellog WHERE deletedfor=?", decoded_uid); err != nil { + if _, err = tx.Exec("DELETE FROM im_dellog WHERE deletedfor=?", decoded_uid); err != nil { return err } @@ -2077,37 +2108,37 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { // Delete topics where the user is the owner. if len(ownTopics) > 0 { // First delete all messages in those topics. - if _, err = tx.Exec("DELETE dellog FROM dellog JOIN topics ON topics.name=dellog.topic WHERE topics.owner=?", + if _, err = tx.Exec("DELETE im_dellog FROM im_dellog JOIN im_topics ON im_topics.name=im_dellog.topic WHERE im_topics.owner=?", decoded_uid); err != nil { return err } // Deletion of messages will cascade to filemsglinks and so to fileuploads. - if _, err = tx.Exec("DELETE messages FROM messages JOIN topics ON topics.name=messages.topic WHERE topics.owner=?", + if _, err = tx.Exec("DELETE im_messages FROM im_messages JOIN im_topics ON im_topics.name=im_messages.topic WHERE im_topics.owner=?", decoded_uid); err != nil { return err } // Delete subscriptions for all users where the user is the owner of the topic. - sql, args, _ := sqlx.In("DELETE FROM subscriptions AS s WHERE topic IN (?)", ownTopics) + sql, args, _ := sqlx.In("DELETE FROM im_subscriptions AS s WHERE topic IN (?)", ownTopics) if _, err = tx.Exec(tx.Rebind(sql), args); err != nil { return err } // Delete topic tags. - if _, err = tx.Exec("DELETE tt FROM topictags AS tt JOIN topics AS t ON t.name=tt.topic WHERE t.owner=?", + if _, err = tx.Exec("DELETE tt FROM im_topictags AS tt JOIN im_topics AS t ON t.name=tt.topic WHERE t.owner=?", decoded_uid); err != nil { return err } // And finally delete the topics. - if _, err = tx.Exec("DELETE FROM topics WHERE owner=?", decoded_uid); err != nil { + if _, err = tx.Exec("DELETE FROM im_topics WHERE owner=?", decoded_uid); err != nil { return err } } // Delete user's authentication records. - if _, err = tx.Exec("DELETE FROM auth WHERE userid=?", decoded_uid); err != nil { + if _, err = tx.Exec("DELETE FROM im_auth WHERE userid=?", decoded_uid); err != nil { return err } @@ -2116,11 +2147,11 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { return err } - if _, err = tx.Exec("DELETE FROM usertags WHERE userid=?", decoded_uid); err != nil { + if _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid); err != nil { return err } - if _, err = tx.Exec("DELETE FROM users WHERE id=?", decoded_uid); err != nil { + if _, err = tx.Exec("DELETE FROM im_users WHERE id=?", decoded_uid); err != nil { return err } } else { @@ -2131,20 +2162,20 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { if len(ownTopics) > 0 { // Disable all subscriptions to topics where the user is the owner. - sql, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics) + sql, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics) if _, err = tx.Exec(tx.Rebind(sql), args...); err != nil { return err } } // Disable group topics where the user is the owner. - if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE owner=?", + if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE owner=?", now, now, t.StateDeleted, now, decoded_uid); err != nil { return err } // Disable p2p topics with the user (p2p topic's owner is 0). - if _, err = tx.Exec("UPDATE topics AS t JOIN subscriptions AS s ON t.name=s.topic "+ + if _, err = tx.Exec("UPDATE im_topics AS t JOIN im_subscriptions AS s ON t.name=s.topic "+ "SET t.updatedat=?,t.touchedat=?,t.state=?,t.stateat=? "+ "WHERE t.owner=0 AND s.userid=? AND t.name LIKE 'p2p%'", now, now, t.StateDeleted, now, decoded_uid); err != nil { @@ -2152,7 +2183,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // Disable the other user's subscription to a disabled p2p topic. - if _, err = tx.Exec("UPDATE subscriptions AS s_one JOIN subscriptions AS s_two "+ + if _, err = tx.Exec("UPDATE im_subscriptions AS s_one JOIN im_subscriptions AS s_two "+ "ON s_one.topic=s_two.topic "+ "SET s_two.updatedat=?, s_two.deletedat=? WHERE s_one.userid=? AND s_one.topic LIKE 'p2p%'", now, now, decoded_uid); err != nil { @@ -2160,7 +2191,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // Finally disable user. - if _, err = tx.Exec("UPDATE users SET updatedat=?,state=?,stateat=? WHERE id=?", + if _, err = tx.Exec("UPDATE im_users SET updatedat=?,state=?,stateat=? WHERE id=?", now, t.StateDeleted, now, decoded_uid); err != nil { return err } @@ -2184,14 +2215,14 @@ func (a *adapter) topicStateForUser(tx *sqlx.Tx, decoded_uid int64, now time.Tim } // Change state of all topics where the user is the owner. - if _, err = tx.Exec("UPDATE topics SET state=?, stateat=? WHERE owner=? AND state!=?", + if _, err = tx.Exec("UPDATE im_topics SET state=?, stateat=? WHERE owner=? AND state!=?", state, now, decoded_uid, t.StateDeleted); err != nil { return err } // Change state of p2p topics with the user (p2p topic's owner is 0) - if _, err = tx.Exec("UPDATE topics JOIN subscriptions ON topics.name=subscriptions.topic "+ - "SET topics.state=?, topics.stateat=? WHERE topics.owner=0 AND subscriptions.userid=? AND topics.state!=?", + if _, err = tx.Exec("UPDATE im_topics JOIN im_subscriptions ON im_topics.name=im_subscriptions.topic "+ + "SET im_topics.state=?, im_topics.stateat=? WHERE im_topics.owner=0 AND im_subscriptions.userid=? AND im_topics.state!=?", state, now, decoded_uid, t.StateDeleted); err != nil { return err } @@ -2221,7 +2252,7 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error { cols, args := common.UpdateByMap(update) decoded_uid := store.DecodeUid(uid) args = append(args, decoded_uid) - _, err = tx.Exec("UPDATE users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) + _, err = tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) if err != nil { return err } @@ -2237,12 +2268,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 usertags WHERE userid=?", decoded_uid) + _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid) if err != nil { return err } // Now insert new tags - err = addTags(tx, "usertags", "userid", decoded_uid, tags, false) + err = addTags(tx, "im_usertags", "userid", decoded_uid, tags, false) if err != nil { return err } @@ -2272,7 +2303,7 @@ func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]stri if reset != nil { // Delete all tags first if resetting. - _, err = tx.Exec("DELETE FROM usertags WHERE userid=?", decoded_uid) + _, err = tx.Exec("DELETE FROM im_usertags WHERE userid=?", decoded_uid) if err != nil { return nil, err } @@ -2281,24 +2312,24 @@ func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]stri } // Now insert new tags. Ignore duplicates if resetting. - err = addTags(tx, "usertags", "userid", decoded_uid, add, reset == nil) + err = addTags(tx, "im_usertags", "userid", decoded_uid, add, reset == nil) if err != nil { return nil, err } // Delete tags. - err = removeTags(tx, "usertags", "userid", decoded_uid, remove) + 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 usertags WHERE userid=?", decoded_uid) + err = tx.Select(&allTags, "SELECT tag FROM im_usertags WHERE userid=?", decoded_uid) if err != nil { return nil, err } - _, err = tx.Exec("UPDATE users SET tags=? WHERE id=?", t.StringSlice(allTags), decoded_uid) + _, err = tx.Exec("UPDATE im_users SET tags=? WHERE id=?", t.StringSlice(allTags), decoded_uid) if err != nil { return nil, err } @@ -2313,7 +2344,7 @@ 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 credentials WHERE synthetic=?", method+":"+value) + 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 } @@ -2340,7 +2371,7 @@ func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string) } var decodedUID int64 err = a.db.GetContext(ctx, &decodedUID, - "SELECT userid FROM credentials WHERE tenant_id=? AND synthetic=?", tenantID, method+":"+value) + "SELECT userid FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, method+":"+value) if err == sql.ErrNoRows { return t.ZeroUid, nil } @@ -2369,7 +2400,7 @@ func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) { } // FIXME: support channels (for channels subscriptions.topic != topics.name). - q, args, _ := sqlx.In("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM topics AS t, subscriptions AS s "+ + q, args, _ := sqlx.In("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM im_topics AS t, im_subscriptions AS s "+ "WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND "+ "INSTR(s.modewant, 'R')>0 AND INSTR(s.modegiven, 'R')>0 GROUP BY s.userid", uids, int(t.StateDeleted)) rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...) @@ -2404,8 +2435,8 @@ func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([] } rows, err := a.db.QueryxContext(ctx, - "SELECT u.id, IFNULL(SUM(c.done),0) AS total FROM users AS u "+ - "LEFT JOIN credentials AS c ON u.id=c.userid WHERE u.lastseen IS NULL AND u.updatedat 0 { q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " + - "FROM topics WHERE name IN (?)" + "FROM im_topics WHERE name IN (?)" q, args, _ = sqlx.In(q, topq) if !keepDeleted { @@ -2747,7 +2778,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( // Fetch p2p users and join to p2p subscriptions. if len(usrq) > 0 { q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " + - "FROM users WHERE id IN (?)" + "FROM im_users WHERE id IN (?)" q, args, _ = sqlx.In(q, usrq) if !keepDeleted { // Optionally skip deleted users. @@ -2810,7 +2841,7 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt // Fetch all subscribed users. The number of users is not large. q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private - FROM subscriptions AS s JOIN users AS u ON s.userid=u.id + FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id WHERE s.topic=?` args := []any{topic} if !keepDeleted { @@ -2957,13 +2988,13 @@ func (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...a // OwnTopics loads a slice of topic names where the user is the owner. func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) { - return a.topicNamesForUser("SELECT name FROM topics WHERE owner=? AND state!=?", + return a.topicNamesForUser("SELECT name FROM im_topics WHERE owner=? AND state!=?", false, store.DecodeUid(uid), t.StateDeleted) } // ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled. func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) { - return a.topicNamesForUser("SELECT topic FROM subscriptions WHERE userid=? AND topic LIKE 'chn%' "+ + return a.topicNamesForUser("SELECT topic FROM im_subscriptions WHERE userid=? AND topic LIKE 'chn%' "+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", false, store.DecodeUid(uid)) } @@ -2993,7 +3024,7 @@ func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error { if topic != "" { // Update topic's subscription count. - if _, err = tx.Exec("UPDATE topics SET subcnt=subcnt+? WHERE name=?", len(shares), topic); err != nil { + if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt+? WHERE name=?", len(shares), topic); err != nil { return err } } @@ -3026,7 +3057,7 @@ func (a *adapter) TopicDelete(topic string, isChan, hard bool) error { if hard { // Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions. - q, args, _ := sqlx.In("DELETE FROM subscriptions WHERE topic IN (?)", args) + q, args, _ := sqlx.In("DELETE FROM im_subscriptions WHERE topic IN (?)", args) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } @@ -3035,22 +3066,22 @@ func (a *adapter) TopicDelete(topic string, isChan, hard bool) error { return err } - if _, err = tx.Exec("DELETE FROM topictags WHERE topic=?", topic); err != nil { + if _, err = tx.Exec("DELETE FROM im_topictags WHERE topic=?", topic); err != nil { return err } - if _, err = tx.Exec("DELETE FROM topics WHERE name=?", topic); err != nil { + if _, err = tx.Exec("DELETE FROM im_topics WHERE name=?", topic); err != nil { return err } } else { now := t.TimeNow() - q, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args) + q, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } - if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE name=?", + if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE name=?", now, now, t.StateDeleted, now, topic); err != nil { return err } @@ -3064,7 +3095,7 @@ func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error { if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, "UPDATE topics SET seqid=?,touchedat=? WHERE name=?", msg.SeqId, msg.CreatedAt, topic) + _, err := a.db.ExecContext(ctx, "UPDATE im_topics SET seqid=?,touchedat=? WHERE name=?", msg.SeqId, msg.CreatedAt, topic) return err } @@ -3076,7 +3107,7 @@ func (a *adapter) TopicUpdateSubCnt(topic string) error { defer cancel() } _, err := a.db.ExecContext(ctx, - "UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?", + "UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?", topic, t.GrpToChn(topic), topic) return err } @@ -3104,7 +3135,7 @@ func (a *adapter) TopicUpdate(topic string, update map[string]any) error { } cols, args := common.UpdateByMap(update) args = append(args, topic) - _, err = tx.Exec("UPDATE topics SET "+strings.Join(cols, ",")+" WHERE name=?", args...) + _, err = tx.Exec("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE name=?", args...) if err != nil { return err } @@ -3112,12 +3143,12 @@ func (a *adapter) TopicUpdate(topic string, 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 topictags WHERE topic=?", topic) + _, err = tx.Exec("DELETE FROM im_topictags WHERE topic=?", topic) if err != nil { return err } // Now insert new tags - err = addTags(tx, "topictags", "topic", topic, tags, false) + err = addTags(tx, "im_topictags", "topic", topic, tags, false) if err != nil { return err } @@ -3131,7 +3162,7 @@ func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error { if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, "UPDATE topics SET owner=? WHERE name=?", store.DecodeUid(newOwner), topic) + _, err := a.db.ExecContext(ctx, "UPDATE im_topics SET owner=? WHERE name=?", store.DecodeUid(newOwner), topic) return err } @@ -3142,7 +3173,7 @@ func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (* defer cancel() } query := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=? AND userid=?` if !keepDeleted { query += " AND deletedat IS NULL" } @@ -3166,7 +3197,7 @@ func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (* // not load deleted subscriptions. func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) { q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven FROM subscriptions WHERE userid=? AND deletedat IS NULL` + readseqid,modewant,modegiven FROM im_subscriptions WHERE userid=? AND deletedat IS NULL` args := []any{store.DecodeUid(forUser)} ctx, cancel := a.getContext() @@ -3200,7 +3231,7 @@ func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) { // the latter does not. func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=?` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=?` args := []any{topic} if !keepDeleted { @@ -3270,7 +3301,7 @@ func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) er }() cols, args := common.UpdateByMap(update) - q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?" + q := "UPDATE im_subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?" args = append(args, topic) if !user.IsZero() { // Update just one topic subscription @@ -3308,7 +3339,7 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { // Mark subscription as deleted. res, err := tx.ExecContext(ctx, - "UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", + "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decoded_id) if err != nil { return err @@ -3324,7 +3355,7 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { // Channel readers cannot delete messages. if !t.IsChannel(topic) { // Remove records of messages soft-deleted by this user. - _, err = tx.Exec("DELETE FROM dellog WHERE topic=? AND deletedfor=?", topic, decoded_id) + _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=? AND deletedfor=?", topic, decoded_id) if err != nil { return err } @@ -3332,7 +3363,7 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { if t.GetTopicCat(topic) == t.TopicCatGrp { // Decrement topic subscription count (only one subscription is deleted). - _, err = tx.Exec("UPDATE topics SET subcnt=subcnt-1 WHERE name=?", t.ChnToGrp(topic)) + _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt-1 WHERE name=?", t.ChnToGrp(topic)) if err != nil { return err } @@ -3344,7 +3375,7 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { // subsDelForUser marks user's subscriptions as deleted. func subsDelForUser(tx *sqlx.Tx, decoded_uid int64, hard bool) error { // Decrement subscription count for all topics the user is subscribed to. - rows, err := tx.Query("SELECT topic FROM subscriptions WHERE userid=? AND deletedat IS NULL", decoded_uid) + rows, err := tx.Query("SELECT topic FROM im_subscriptions WHERE userid=? AND deletedat IS NULL", decoded_uid) if err != nil { return err } @@ -3368,7 +3399,7 @@ func subsDelForUser(tx *sqlx.Tx, decoded_uid int64, hard bool) error { return err } if len(topics) > 0 { - sql, args, err := sqlx.In("UPDATE topics SET subcnt=subcnt-1 WHERE name IN (?)", topics) + sql, args, err := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE name IN (?)", topics) _, err = tx.Exec(tx.Rebind(sql), args...) if err != nil { return err @@ -3376,10 +3407,10 @@ func subsDelForUser(tx *sqlx.Tx, decoded_uid int64, hard bool) error { } if hard { - _, err = tx.Exec("DELETE FROM subscriptions WHERE userid=?", decoded_uid) + _, err = tx.Exec("DELETE FROM im_subscriptions WHERE userid=?", decoded_uid) } else { now := t.TimeNow() - _, err = tx.Exec("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE userid=? AND deletedat IS NULL", + _, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE userid=? AND deletedat IS NULL", now, now, decoded_uid) } return err @@ -3434,7 +3465,7 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, } query := "SELECT u.id,u.createdat,u.updatedat,0,u.access,0 AS subcnt,u.public,u.trusted,u.tags," + matcher + " AS matches " + - "FROM users AS u JOIN usertags AS tg ON tg.userid=u.id " + + "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 { @@ -3454,7 +3485,7 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, } 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 topics AS t JOIN topictags AS tg ON t.name=tg.topic " + + "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) + ") " + "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags " if len(allReq) > 0 { @@ -3528,13 +3559,13 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, func (a *adapter) FindOne(tag string) (string, error) { var args []any - query := "SELECT t.name AS topic FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic " + + 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 users AS u LEFT JOIN usertags AS ut ON ut.userid=u.id " + + 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) @@ -3580,7 +3611,7 @@ func (a *adapter) MessageSave(msg *t.Message) error { // store assignes message ID, but we don't use it. Message IDs are not used anywhere. // Using a sequential ID provided by the database. res, err := a.db.ExecContext(ctx, - "INSERT INTO messages(createdAt,updatedAt,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?)", + "INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?)", msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, store.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content)) if err == nil { @@ -3633,7 +3664,7 @@ func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ( rows, err := a.db.QueryxContext( ctx, "SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content"+ - " FROM messages AS m LEFT JOIN dellog AS d"+ + " FROM im_messages AS m LEFT JOIN im_dellog AS d"+ " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+ " WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+ " ORDER BY m.seqid DESC LIMIT ?", @@ -3685,7 +3716,7 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, "SELECT topic,deletedfor,delid,low,hi FROM dellog WHERE topic=? AND delid BETWEEN ? AND ?"+ + rows, err := a.db.QueryxContext(ctx, "SELECT topic,deletedfor,delid,low,hi FROM im_dellog WHERE topic=? AND delid BETWEEN ? AND ?"+ " AND (deletedFor=0 OR deletedFor=?)"+ " ORDER BY delid LIMIT ?", topic, lower, upper, store.DecodeUid(forUser), limit) if err != nil { @@ -3744,9 +3775,9 @@ func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error { if toDel == nil { // Whole topic is being deleted, thus also deleting all messages. - _, err = tx.Exec("DELETE FROM dellog WHERE topic=?", topic) + _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic) if err == nil { - _, err = tx.Exec("DELETE FROM messages WHERE topic=?", topic) + _, err = tx.Exec("DELETE FROM im_messages WHERE topic=?", topic) } // filemsglinks will be deleted because of ON DELETE CASCADE return err @@ -3777,7 +3808,7 @@ func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error { // Find the actual IDs still present in the database. var seqIDs []int - err = tx.Select(&seqIDs, "SELECT seqid FROM messages AS m WHERE "+where, args...) + err = tx.Select(&seqIDs, "SELECT seqid FROM im_messages AS m WHERE "+where, args...) if err != nil { return err } @@ -3800,14 +3831,14 @@ func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error { // No need to add anything else: deletedat etc is already accounted for. - _, err = tx.Exec("DELETE fml.* FROM filemsglinks AS fml INNER JOIN messages AS m ON m.id=fml.msgid WHERE "+ + _, err = tx.Exec("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN im_messages AS m ON m.id=fml.msgid WHERE "+ where, args...) if err != nil { return err } // Instead of deleting messages, clear all content. - _, err = tx.Exec("UPDATE messages AS m SET m.deletedat=?,m.delId=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+ + _, err = tx.Exec("UPDATE im_messages AS m SET m.deletedat=?,m.delId=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+ where, append([]any{t.TimeNow(), toDel.DelId}, args...)...) if err != nil { return err @@ -3817,7 +3848,7 @@ func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error { // Now make log entries. Needed for both hard- and soft-deleting. var insert *sql.Stmt if insert, err = tx.Prepare( - "INSERT INTO dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)"); err != nil { + "INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)"); err != nil { return err } @@ -3889,13 +3920,13 @@ func (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error { }() // Ensure uniqueness of the device ID: delete all records of the device ID - _, err = tx.Exec("DELETE FROM devices WHERE hash=?", hash) + _, err = tx.Exec("DELETE FROM im_devices WHERE hash=?", hash) if err != nil { return err } // Actually add/update DeviceId for the new user - _, err = tx.Exec("INSERT INTO devices(userid, hash, deviceId, platform, lastseen, lang) VALUES(?,?,?,?,?,?)", + _, err = tx.Exec("INSERT INTO im_devices(userid, hash, deviceId, platform, lastseen, lang) VALUES(?,?,?,?,?,?)", store.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang) if err != nil { return err @@ -3911,7 +3942,7 @@ func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, err unums = append(unums, store.DecodeUid(uid)) } - q, unums, _ := sqlx.In("SELECT userid,deviceid,platform,lastseen,lang FROM devices WHERE userid IN (?)", unums) + q, unums, _ := sqlx.In("SELECT userid,deviceid,platform,lastseen,lang FROM im_devices WHERE userid IN (?)", unums) ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -3958,9 +3989,9 @@ func deviceDelete(tx *sqlx.Tx, uid t.Uid, deviceID string) error { var err error var res sql.Result if deviceID == "" { - res, err = tx.Exec("DELETE FROM devices WHERE userid=?", store.DecodeUid(uid)) + res, err = tx.Exec("DELETE FROM im_devices WHERE userid=?", store.DecodeUid(uid)) } else { - res, err = tx.Exec("DELETE FROM devices WHERE userid=? AND hash=?", store.DecodeUid(uid), deviceHasher(deviceID)) + res, err = tx.Exec("DELETE FROM im_devices WHERE userid=? AND hash=?", store.DecodeUid(uid), deviceHasher(deviceID)) } if err == nil { @@ -4034,7 +4065,7 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { if !cred.Done { // Check if this credential is already validated. var done bool - err = tx.Get(&done, "SELECT done FROM credentials WHERE synthetic=?", synth) + err = tx.Get(&done, "SELECT done FROM im_credentials WHERE synthetic=?", synth) if err == nil { // Assign err to ensure closing of a transaction. err = t.ErrDuplicate @@ -4047,13 +4078,13 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { synth = cred.User + ":" + synth // Adding new unvalidated credential. Deactivate all unvalidated records of this user and method. - _, err = tx.Exec("UPDATE credentials SET deletedat=? WHERE userid=? AND method=? AND done=FALSE", + _, err = tx.Exec("UPDATE im_credentials SET deletedat=? WHERE userid=? AND method=? AND done=FALSE", now, userId, cred.Method) if err != nil { return false, err } // Assume that the record exists and try to update it: undelete, update timestamp and response value. - res, err := tx.Exec("UPDATE credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE synthetic=?", + res, err := tx.Exec("UPDATE im_credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE synthetic=?", cred.UpdatedAt, cred.Resp, synth) if err != nil { return false, err @@ -4064,13 +4095,13 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { } } else { // Hard-deleting unconformed record if it exists. - _, err = tx.Exec("DELETE FROM credentials WHERE synthetic=?", cred.User+":"+synth) + _, err = tx.Exec("DELETE FROM im_credentials WHERE synthetic=?", cred.User+":"+synth) if err != nil { return false, err } } // Add new record. - _, err = tx.Exec("INSERT INTO credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ + _, err = tx.Exec("INSERT INTO im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ "VALUES(?,?,?,?,?,?,?,?)", cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done) if err != nil { @@ -4110,7 +4141,7 @@ func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (boo synth := cred.Method + ":" + cred.Value if !cred.Done { var done bool - err = tx.Get(&done, "SELECT done FROM credentials WHERE tenant_id=? AND synthetic=?", tenantID, synth) + err = tx.Get(&done, "SELECT done FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, synth) if err == nil { return false, t.ErrDuplicate } @@ -4118,12 +4149,12 @@ func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (boo return false, err } synth = cred.User + ":" + synth - _, err = tx.Exec("UPDATE credentials SET deletedat=? WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE", + _, err = tx.Exec("UPDATE im_credentials SET deletedat=? WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE", now, tenantID, userID, cred.Method) if err != nil { return false, err } - res, err := tx.Exec("UPDATE credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE "+ + res, err := tx.Exec("UPDATE im_credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE "+ "WHERE tenant_id=? AND synthetic=?", cred.UpdatedAt, cred.Resp, tenantID, synth) if err != nil { return false, err @@ -4132,14 +4163,14 @@ func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (boo return false, tx.Commit() } } else { - _, err = tx.Exec("DELETE FROM credentials WHERE tenant_id=? AND synthetic=?", + _, err = tx.Exec("DELETE FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, cred.User+":"+synth) if err != nil { return false, err } } - _, err = tx.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+ + _, err = tx.Exec("INSERT INTO im_credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+ "VALUES(?,?,?,?,?,?,?,?,?)", tenantID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userID, cred.Resp, cred.Done) if isDupe(err) { @@ -4164,7 +4195,7 @@ func tenantCredDel(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, method, value st } if method == "" { - res, err := tx.Exec("DELETE FROM credentials"+constraints, args...) + res, err := tx.Exec("DELETE FROM im_credentials"+constraints, args...) if err != nil { return err } @@ -4174,7 +4205,7 @@ func tenantCredDel(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, method, value st return nil } - res, err := tx.Exec("DELETE FROM credentials"+constraints+" AND (done=TRUE OR retries=0)", args...) + res, err := tx.Exec("DELETE FROM im_credentials"+constraints+" AND (done=TRUE OR retries=0)", args...) if err != nil { return err } @@ -4183,7 +4214,7 @@ func tenantCredDel(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, method, value st } args = append([]any{t.TimeNow()}, args...) - res, err = tx.Exec("UPDATE credentials SET deletedat=?"+constraints, args...) + res, err = tx.Exec("UPDATE im_credentials SET deletedat=?"+constraints, args...) if err != nil { return err } @@ -4217,7 +4248,7 @@ func credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error { var res sql.Result if method == "" { // Case 1 - res, err = tx.Exec("DELETE FROM credentials"+constraints, args...) + res, err = tx.Exec("DELETE FROM im_credentials"+constraints, args...) if err == nil { if count, _ := res.RowsAffected(); count == 0 { err = t.ErrNotFound @@ -4227,7 +4258,7 @@ func credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error { } // Case 2.1 - res, err = tx.Exec("DELETE FROM credentials"+constraints+" AND (done=TRUE OR retries=0)", args...) + res, err = tx.Exec("DELETE FROM im_credentials"+constraints+" AND (done=TRUE OR retries=0)", args...) if err != nil { return err } @@ -4237,7 +4268,7 @@ func credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error { // Case 2.2 args = append([]any{t.TimeNow()}, args...) - res, err = tx.Exec("UPDATE credentials SET deletedat=?"+constraints, args...) + res, err = tx.Exec("UPDATE im_credentials SET deletedat=?"+constraints, args...) if err == nil { if count, _ := res.RowsAffected(); count >= 0 { err = t.ErrNotFound @@ -4305,7 +4336,7 @@ func (a *adapter) CredConfirm(uid t.Uid, method string) error { } res, err := a.db.ExecContext( ctx, - "UPDATE credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+ + "UPDATE im_credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+ "WHERE userid=? AND method=? AND deletedat IS NULL AND done=FALSE", t.TimeNow(), store.DecodeUid(uid), method) if err != nil { @@ -4334,7 +4365,7 @@ func (a *adapter) TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method strin defer cancel() } res, err := a.db.ExecContext(ctx, - "UPDATE credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+ + "UPDATE im_credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+ "WHERE tenant_id=? AND userid=? AND method=? AND deletedat IS NULL AND done=FALSE", t.TimeNow(), tenantID, store.DecodeUid(uid), method) if isDupe(err) { @@ -4355,7 +4386,7 @@ func (a *adapter) CredFail(uid t.Uid, method string) error { if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, "UPDATE credentials SET updatedat=?,retries=retries+1 WHERE userid=? AND method=? AND done=FALSE", + _, err := a.db.ExecContext(ctx, "UPDATE im_credentials SET updatedat=?,retries=retries+1 WHERE userid=? AND method=? AND done=FALSE", t.TimeNow(), store.DecodeUid(uid), method) return err } @@ -4373,7 +4404,7 @@ func (a *adapter) TenantCredFail(tenantID t.TenantID, uid t.Uid, method string) if cancel != nil { defer cancel() } - _, err = a.db.ExecContext(ctx, "UPDATE credentials SET updatedat=?,retries=retries+1 "+ + _, err = a.db.ExecContext(ctx, "UPDATE im_credentials SET updatedat=?,retries=retries+1 "+ "WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE", t.TimeNow(), tenantID, store.DecodeUid(uid), method) return err @@ -4387,7 +4418,7 @@ func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) } var cred t.Credential err := a.db.GetContext(ctx, &cred, "SELECT createdat,updatedat,method,value,resp,done,retries "+ - "FROM credentials WHERE userid=? AND deletedat IS NULL AND method=? AND done=FALSE", + "FROM im_credentials WHERE userid=? AND deletedat IS NULL AND method=? AND done=FALSE", store.DecodeUid(uid), method) if err != nil { if err == sql.ErrNoRows { @@ -4415,7 +4446,7 @@ func (a *adapter) TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method str } var cred t.Credential err = a.db.GetContext(ctx, &cred, "SELECT createdat,updatedat,method,value,resp,done,retries "+ - "FROM credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL AND method=? AND done=FALSE", + "FROM im_credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL AND method=? AND done=FALSE", tenantID, store.DecodeUid(uid), method) if err == sql.ErrNoRows { return nil, nil @@ -4429,7 +4460,7 @@ func (a *adapter) TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method str // CredGetAll returns credential records for the given user and method, all or validated only. func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) { - query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials WHERE userid=? AND deletedat IS NULL" + query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM im_credentials WHERE userid=? AND deletedat IS NULL" args := []any{store.DecodeUid(uid)} if method != "" { query += " AND method=?" @@ -4467,7 +4498,7 @@ func (a *adapter) TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string if tenantAdp != a { return tenantAdp.CredGetAll(uid, method, validatedOnly) } - query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials " + + query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM im_credentials " + "WHERE tenant_id=? AND userid=? AND deletedat IS NULL" args := []any{tenantID, store.DecodeUid(uid)} if method != "" { @@ -4507,7 +4538,7 @@ func (a *adapter) FileStartUpload(fd *t.FileDef) error { user = 0 } _, err := a.db.ExecContext(ctx, - "INSERT INTO fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+ + "INSERT INTO im_fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+ "VALUES(?,?,?,?,?,?,?,?,?)", store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) @@ -4532,7 +4563,7 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t. now := t.TimeNow() if success { - _, err = tx.ExecContext(ctx, "UPDATE fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE id=?", + _, err = tx.ExecContext(ctx, "UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE id=?", now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid())) if err != nil { return nil, err @@ -4542,7 +4573,7 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t. fd.Size = size } else { // Deleting the record: there is no value in keeping it in the DB. - _, err = tx.ExecContext(ctx, "DELETE FROM fileuploads WHERE id=?", store.DecodeUid(fd.Uid())) + _, err = tx.ExecContext(ctx, "DELETE FROM im_fileuploads WHERE id=?", store.DecodeUid(fd.Uid())) if err != nil { return nil, err } @@ -4568,7 +4599,7 @@ func (a *adapter) FileGet(fid string) (*t.FileDef, error) { } var fd t.FileDef err := a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ - "FROM fileuploads WHERE id=?", store.DecodeUid(id)) + "FROM im_fileuploads WHERE id=?", store.DecodeUid(id)) if err == sql.ErrNoRows { return nil, nil } @@ -4601,7 +4632,7 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er }() // Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned. - query := "SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.fileid=fu.id " + + query := "SELECT fu.id,fu.location FROM im_fileuploads AS fu LEFT JOIN im_filemsglinks AS fml ON fml.fileid=fu.id " + "WHERE fml.id IS NULL" var args []any if !olderThan.IsZero() { @@ -4641,7 +4672,7 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er } if len(ids) > 0 { - query, ids, _ = sqlx.In("DELETE FROM fileuploads WHERE id IN (?)", ids) + query, ids, _ = sqlx.In("DELETE FROM im_fileuploads WHERE id IN (?)", ids) _, err = tx.Exec(query, ids...) if err != nil { return nil, err @@ -4707,14 +4738,14 @@ func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids [] // Unlink earlier uploads on the same topic or user allowing them to be garbage-collected. if msgId.IsZero() { - sql := "DELETE FROM filemsglinks WHERE " + linkBy + "=?" + sql := "DELETE FROM im_filemsglinks WHERE " + linkBy + "=?" _, err = tx.Exec(sql, linkId) if err != nil { return err } } - sql := "INSERT INTO filemsglinks(createdat,fileid," + linkBy + ") VALUES (?,?,?)" + sql := "INSERT INTO im_filemsglinks(createdat,fileid," + linkBy + ") VALUES (?,?,?)" _, err = tx.Exec(sql+strings.Repeat(",(?,?,?)", len(dids)-1), args...) if err != nil { return err @@ -4737,31 +4768,31 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { } var allowed int err := a.db.GetContext(ctx, &allowed, ` - SELECT COUNT(*) FROM fileuploads AS fu + SELECT COUNT(*) FROM im_fileuploads AS fu WHERE fu.id=? AND fu.status=? AND ( fu.userid=? OR EXISTS ( - SELECT 1 FROM filemsglinks AS fml + SELECT 1 FROM im_filemsglinks AS fml WHERE fml.fileid=fu.id AND fml.userid=? ) OR EXISTS ( - SELECT 1 FROM filemsglinks AS fml - INNER JOIN subscriptions AS s + SELECT 1 FROM im_filemsglinks AS fml + INNER JOIN im_subscriptions AS s ON s.topic=fml.topic AND s.userid=? WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 ) OR EXISTS ( - SELECT 1 FROM filemsglinks AS fml - INNER JOIN messages AS m + SELECT 1 FROM im_filemsglinks AS fml + INNER JOIN im_messages AS m ON m.id=fml.msgid - INNER JOIN subscriptions AS s + INNER JOIN im_subscriptions AS s ON s.topic=m.topic AND s.userid=? WHERE fml.fileid=fu.id AND fml.msgid IS NOT NULL AND m.deletedat IS NULL AND m.recalled_at IS NULL AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 AND NOT EXISTS ( - SELECT 1 FROM dellog AS d + SELECT 1 FROM im_dellog AS d WHERE d.topic=m.topic AND d.deletedfor=? AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid) ) @@ -4782,7 +4813,7 @@ func (a *adapter) PCacheGet(key string) (string, error) { } var value string - if err := a.db.GetContext(ctx, &value, "SELECT `value` FROM kvmeta WHERE `key`=? LIMIT 1", key); err != nil { + if err := a.db.GetContext(ctx, &value, "SELECT `value` FROM im_kvmeta WHERE `key`=? LIMIT 1", key); err != nil { if err == sql.ErrNoRows { return "", t.ErrNotFound } @@ -4810,7 +4841,7 @@ func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) e action = "REPLACE" } - _, err := a.db.ExecContext(ctx, action+" INTO kvmeta(`key`,createdat,`value`) VALUES(?,?,?)", key, t.TimeNow(), value) + _, err := a.db.ExecContext(ctx, action+" INTO im_kvmeta(`key`,createdat,`value`) VALUES(?,?,?)", key, t.TimeNow(), value) if isDupe(err) { return t.ErrDuplicate } @@ -4824,7 +4855,7 @@ func (a *adapter) PCacheDelete(key string) error { defer cancel() } - _, err := a.db.ExecContext(ctx, "DELETE FROM kvmeta WHERE `key`=?", key) + _, err := a.db.ExecContext(ctx, "DELETE FROM im_kvmeta WHERE `key`=?", key) return err } @@ -4839,7 +4870,7 @@ func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error { defer cancel() } - _, err := a.db.ExecContext(ctx, "DELETE FROM kvmeta WHERE `key` LIKE ? AND createdat 0 { - if _, err = tx.Exec("DELETE dl FROM dellog AS dl JOIN topics AS tp ON tp.tenant_id=dl.tenant_id AND tp.name=dl.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE dl FROM im_dellog AS dl JOIN im_topics AS tp ON tp.tenant_id=dl.tenant_id AND tp.name=dl.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { return err } - if _, err = tx.Exec("DELETE m FROM messages AS m JOIN topics AS tp ON tp.tenant_id=m.tenant_id AND tp.name=m.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE m FROM im_messages AS m JOIN im_topics AS tp ON tp.tenant_id=m.tenant_id AND tp.name=m.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { return err } - sqlq, params, _ := sqlx.In("DELETE FROM subscriptions WHERE tenant_id=? AND topic IN (?)", id, ownAll) + sqlq, params, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, ownAll) if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil { return err } - if _, err = tx.Exec("DELETE tt FROM topictags AS tt JOIN topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE tt FROM im_topictags AS tt JOIN im_topics AS tp ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic WHERE tp.tenant_id=? AND tp.owner=?", id, decoded); err != nil { return err } - if _, err = tx.Exec("DELETE FROM topics WHERE tenant_id=? AND owner=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND owner=?", id, decoded); err != nil { return err } } - if _, err = tx.Exec("DELETE FROM auth WHERE tenant_id=? AND userid=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE FROM im_auth WHERE tenant_id=? AND userid=?", id, decoded); err != nil { return err } - if _, err = tx.Exec("DELETE FROM credentials WHERE tenant_id=? AND userid=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE FROM im_credentials WHERE tenant_id=? AND userid=?", id, decoded); err != nil { return err } - if _, err = tx.Exec("DELETE FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil { + 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 users WHERE tenant_id=? AND id=?", id, decoded); err != nil { + if _, err = tx.Exec("DELETE FROM im_users WHERE tenant_id=? AND id=?", id, decoded); err != nil { return err } } else { @@ -319,26 +319,26 @@ func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error { return err } if len(ownAll) > 0 { - sqlq, params, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, ownAll) + sqlq, params, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, ownAll) if _, err = tx.Exec(tx.Rebind(sqlq), params...); err != nil { return err } } - if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?", + if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND owner=?", now, now, t.StateDeleted, now, id, decoded); err != nil { return err } - if _, err = tx.Exec("UPDATE topics AS tp JOIN subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ + if _, err = tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ "SET tp.updatedat=?,tp.touchedat=?,tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.name LIKE 'p2p%'", now, now, t.StateDeleted, now, id, decoded); err != nil { return err } - if _, err = tx.Exec("UPDATE subscriptions AS s_one JOIN subscriptions AS s_two ON s_one.tenant_id=s_two.tenant_id AND s_one.topic=s_two.topic "+ + if _, err = tx.Exec("UPDATE im_subscriptions AS s_one JOIN im_subscriptions AS s_two ON s_one.tenant_id=s_two.tenant_id AND s_one.topic=s_two.topic "+ "SET s_two.updatedat=?,s_two.deletedat=? WHERE s_one.tenant_id=? AND s_one.userid=? AND s_one.topic LIKE 'p2p%'", now, now, id, decoded); err != nil { return err } - if _, err = tx.Exec("UPDATE users SET updatedat=?,state=?,stateat=? WHERE tenant_id=? AND id=?", + if _, err = tx.Exec("UPDATE im_users SET updatedat=?,state=?,stateat=? WHERE tenant_id=? AND id=?", now, t.StateDeleted, now, id, decoded); err != nil { return err } @@ -354,11 +354,11 @@ func (a *adapter) tenantTopicStateForUser(tx *sqlx.Tx, tenantID t.TenantID, deco if now.IsZero() { now = t.TimeNow() } - if _, err := tx.Exec("UPDATE topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?", + if _, err := tx.Exec("UPDATE im_topics SET state=?,stateat=? WHERE tenant_id=? AND owner=? AND state!=?", objState, now, tenantID, decodedUID, t.StateDeleted); err != nil { return err } - _, err := tx.Exec("UPDATE topics AS tp JOIN subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ + _, err := tx.Exec("UPDATE im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ "SET tp.state=?,tp.stateat=? WHERE tp.tenant_id=? AND tp.owner=0 AND s.userid=? AND tp.state!=?", objState, now, tenantID, decodedUID, t.StateDeleted) return err @@ -391,7 +391,7 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a decoded := decodeUID(uid) cols, args := common.UpdateByMap(update) args = append(args, id, decoded) - res, err := tx.Exec("UPDATE users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...) + res, err := tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND id=?", args...) if err != nil { return err } @@ -405,10 +405,10 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a } } if tags := common.ExtractTags(update); tags != nil { - if _, err = tx.Exec("DELETE FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != 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, "usertags", "userid", decoded, tags, false); err != nil { + if err = addTenantTags(tx, id, "im_usertags", "userid", decoded, tags, false); err != nil { return err } } @@ -441,25 +441,25 @@ func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, re }() decoded := decodeUID(uid) if reset != nil { - if _, err = tx.Exec("DELETE FROM usertags WHERE tenant_id=? AND userid=?", id, decoded); err != 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, "usertags", "userid", decoded, add, reset == nil); err != 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 usertags WHERE tenant_id=? AND userid=? AND tag IN (?)", id, decoded, remove) + 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 usertags WHERE tenant_id=? AND userid=?", id, decoded); err != nil { + 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 users SET tags=? WHERE tenant_id=? AND id=?", t.StringSlice(allTags), id, decoded); err != nil { + 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() @@ -488,7 +488,7 @@ func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid return counts, nil } q, args, _ := sqlx.In("SELECT s.userid,SUM(tp.seqid)-SUM(s.readseqid) AS unreadcount "+ - "FROM topics AS tp JOIN subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ + "FROM im_topics AS tp JOIN im_subscriptions AS s ON tp.tenant_id=s.tenant_id AND tp.name=s.topic "+ "WHERE s.tenant_id=? AND s.userid IN (?) AND s.deletedat IS NULL AND tp.state!=? "+ "AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 GROUP BY s.userid", id, decoded, t.StateDeleted) ctx, cancel := a.getContext() @@ -527,8 +527,8 @@ func (a *adapter) TenantUserGetUnvalidated(id t.TenantID, before time.Time, limi defer cancel() } rows, err := a.db.QueryxContext(ctx, - "SELECT u.id,IFNULL(SUM(c.done),0) AS total FROM users AS u "+ - "LEFT JOIN credentials AS c ON u.tenant_id=c.tenant_id AND u.id=c.userid "+ + "SELECT u.id,IFNULL(SUM(c.done),0) AS total FROM im_users AS u "+ + "LEFT JOIN im_credentials AS c ON u.tenant_id=c.tenant_id AND u.id=c.userid "+ "WHERE u.tenant_id=? AND u.lastseen IS NULL AND u.updatedat 0 { - q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted FROM topics WHERE tenant_id=? AND name IN (?)" + q = "SELECT tenant_id,updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted FROM im_topics WHERE tenant_id=? AND name IN (?)" q, args, _ = sqlx.In(q, id, topq) if !keepDeleted { q += " AND state!=?" @@ -803,7 +803,7 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool } } if len(usrq) > 0 { - q = "SELECT tenant_id,id,updatedat,state,access,lastseen,useragent,public,trusted FROM users WHERE tenant_id=? AND id IN (?)" + q = "SELECT tenant_id,id,updatedat,state,access,lastseen,useragent,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?)" q, args, _ = sqlx.In(q, id, usrq) if !keepDeleted { q += " AND state!=?" @@ -859,7 +859,7 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b tcat := t.GetTopicCat(topic) q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private - FROM subscriptions AS s JOIN users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id + FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id WHERE s.tenant_id=? AND s.topic=?` args := []any{id, topic} if !keepDeleted { @@ -978,7 +978,7 @@ func (a *adapter) TenantOwnTopics(id t.TenantID, uid t.Uid) ([]string, error) { if a.tenantScopedDB { return a.OwnTopics(uid) } - return a.tenantTopicNames("SELECT name FROM topics WHERE tenant_id=? AND owner=? AND state!=?", + return a.tenantTopicNames("SELECT name FROM im_topics WHERE tenant_id=? AND owner=? AND state!=?", id, decodeUID(uid), t.StateDeleted) } @@ -993,7 +993,7 @@ func (a *adapter) TenantChannelsForUser(id t.TenantID, uid t.Uid) ([]string, err if a.tenantScopedDB { return a.ChannelsForUser(uid) } - return a.tenantTopicNames("SELECT topic FROM subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+ + return a.tenantTopicNames("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", id, decodeUID(uid)) } @@ -1032,7 +1032,7 @@ func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscr } } if topic != "" { - if _, err = tx.Exec("UPDATE topics SET subcnt=subcnt+? WHERE tenant_id=? AND name=?", len(subs), id, topic); err != nil { + if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt+? WHERE tenant_id=? AND name=?", len(subs), id, topic); err != nil { return err } } @@ -1041,10 +1041,10 @@ func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscr func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toDel *t.DelMessage) error { if toDel == nil { - if _, err := tx.Exec("DELETE FROM dellog WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil { + if _, err := tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=?", tenantID, topic); err != nil { return err } - _, err := tx.Exec("DELETE FROM messages WHERE tenant_id=? AND topic=?", tenantID, topic) + _, err := tx.Exec("DELETE FROM im_messages WHERE tenant_id=? AND topic=?", tenantID, topic) return err } delRanges := toDel.SeqIdRanges @@ -1062,7 +1062,7 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD args = append(args, newerThan) } var seqIDs []int - if err := tx.Select(&seqIDs, "SELECT seqid FROM messages AS m WHERE "+where, args...); err != nil { + if err := tx.Select(&seqIDs, "SELECT seqid FROM im_messages AS m WHERE "+where, args...); err != nil { return err } if len(seqIDs) == 0 { @@ -1073,15 +1073,15 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD rSQL, rArgs := common.RangesToSql(delRanges) where = "m.tenant_id=? AND m.topic=? AND m.seqid " + rSQL args = append([]any{tenantID, topic}, rArgs...) - if _, err := tx.Exec("DELETE fml.* FROM filemsglinks AS fml INNER JOIN messages AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE "+where, args...); err != nil { + if _, err := tx.Exec("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN im_messages AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid WHERE "+where, args...); err != nil { return err } - if _, err := tx.Exec("UPDATE messages AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+ + if _, err := tx.Exec("UPDATE im_messages AS m SET m.deletedat=?,m.delid=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+ where, append([]any{t.TimeNow(), toDel.DelId}, args...)...); err != nil { return err } } - stmt, err := tx.Prepare("INSERT INTO dellog(tenant_id,topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?,?)") + stmt, err := tx.Prepare("INSERT INTO im_dellog(tenant_id,topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?,?)") if err != nil { return err } @@ -1127,26 +1127,26 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo } }() if hard { - q, args, _ := sqlx.In("DELETE FROM subscriptions WHERE tenant_id=? AND topic IN (?)", id, topics) + q, args, _ := sqlx.In("DELETE FROM im_subscriptions WHERE tenant_id=? AND topic IN (?)", id, topics) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } if err = tenantMessageDeleteList(tx, id, topic, nil); err != nil { return err } - if _, err = tx.Exec("DELETE FROM topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil { + if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil { return err } - if _, err = tx.Exec("DELETE FROM topics WHERE tenant_id=? AND name=?", id, topic); err != nil { + if _, err = tx.Exec("DELETE FROM im_topics WHERE tenant_id=? AND name=?", id, topic); err != nil { return err } } else { now := t.TimeNow() - q, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, topics) + q, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic IN (?)", now, now, id, topics) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } - if _, err = tx.Exec("UPDATE topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?", + if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE tenant_id=? AND name=?", now, now, t.StateDeleted, now, id, topic); err != nil { return err } @@ -1169,7 +1169,7 @@ func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t if cancel != nil { defer cancel() } - _, err = a.db.ExecContext(ctx, "UPDATE topics SET seqid=?,touchedat=? WHERE tenant_id=? AND name=?", + _, err = a.db.ExecContext(ctx, "UPDATE im_topics SET seqid=?,touchedat=? WHERE tenant_id=? AND name=?", msg.SeqId, msg.CreatedAt, id, topic) return err } @@ -1190,7 +1190,7 @@ func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error { defer cancel() } _, err = a.db.ExecContext(ctx, - "UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?", + "UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE tenant_id=? AND topic IN (?,?) AND deletedat IS NULL) WHERE tenant_id=? AND name=?", id, topic, t.GrpToChn(topic), id, topic) return err } @@ -1224,7 +1224,7 @@ func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[stri } cols, args := common.UpdateByMap(update) args = append(args, id, topic) - res, err := tx.Exec("UPDATE topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?", args...) + res, err := tx.Exec("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE tenant_id=? AND name=?", args...) if err != nil { return err } @@ -1232,10 +1232,10 @@ func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[stri return t.ErrNotFound } if tags := common.ExtractTags(update); tags != nil { - if _, err = tx.Exec("DELETE FROM topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil { + if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil { return err } - if err = addTenantTags(tx, id, "topictags", "topic", topic, tags, false); err != nil { + if err = addTenantTags(tx, id, "im_topictags", "topic", topic, tags, false); err != nil { return err } } @@ -1257,7 +1257,7 @@ func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Ui if cancel != nil { defer cancel() } - _, err = a.db.ExecContext(ctx, "UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decodeUID(owner), id, topic) + _, err = a.db.ExecContext(ctx, "UPDATE im_topics SET owner=? WHERE tenant_id=? AND name=?", decodeUID(owner), id, topic) return err } @@ -1281,7 +1281,7 @@ func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid, return sub, err } query := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND topic=? AND userid=?` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND topic=? AND userid=?` if !keepDeleted { query += " AND deletedat IS NULL" } @@ -1316,7 +1316,7 @@ func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription, return stampTenantSubs(id, subs), err } rows, err := a.db.Queryx(`SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven FROM subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`, + readseqid,modewant,modegiven FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`, id, decodeUID(uid)) if err != nil { return nil, err @@ -1348,7 +1348,7 @@ func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bo return stampTenantSubs(id, subs), err } q := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND topic=?` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE tenant_id=? AND topic=?` args := []any{id, topic} if !keepDeleted { q += " AND deletedat IS NULL" @@ -1399,7 +1399,7 @@ func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, updat return a.SubsUpdate(topic, uid, update) } cols, args := common.UpdateByMap(update) - q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?" + q := "UPDATE im_subscriptions SET " + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?" args = append(args, id, topic) if !uid.IsZero() { q += " AND userid=?" @@ -1439,7 +1439,7 @@ func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error }() now := t.TimeNow() decoded := decodeUID(uid) - res, err := tx.Exec("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL", + res, err := tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND topic=? AND userid=? AND deletedat IS NULL", now, now, id, topic, decoded) if err != nil { return err @@ -1448,12 +1448,12 @@ func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error return t.ErrNotFound } if !t.IsChannel(topic) { - if _, err = tx.Exec("DELETE FROM dellog WHERE tenant_id=? AND topic=? AND deletedfor=?", id, topic, decoded); err != nil { + if _, err = tx.Exec("DELETE FROM im_dellog WHERE tenant_id=? AND topic=? AND deletedfor=?", id, topic, decoded); err != nil { return err } } if t.GetTopicCat(topic) == t.TopicCatGrp { - if _, err = tx.Exec("UPDATE topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name=?", id, t.ChnToGrp(topic)); err != nil { + if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name=?", id, t.ChnToGrp(topic)); err != nil { return err } } @@ -1461,7 +1461,7 @@ func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error } func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, hard bool) error { - rows, err := tx.Query("SELECT topic FROM subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL", tenantID, decodedUID) + rows, err := tx.Query("SELECT topic FROM im_subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL", tenantID, decodedUID) if err != nil { return err } @@ -1483,16 +1483,16 @@ func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, ha return err } if len(topics) > 0 { - q, args, _ := sqlx.In("UPDATE topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)", tenantID, topics) + q, args, _ := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE tenant_id=? AND name IN (?)", tenantID, topics) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } } if hard { - _, err = tx.Exec("DELETE FROM subscriptions WHERE tenant_id=? AND userid=?", tenantID, decodedUID) + _, err = tx.Exec("DELETE FROM im_subscriptions WHERE tenant_id=? AND userid=?", tenantID, decodedUID) } else { now := t.TimeNow() - _, err = tx.Exec("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND userid=? AND deletedat IS NULL", + _, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE tenant_id=? AND userid=? AND deletedat IS NULL", now, now, tenantID, decodedUID) } return err @@ -1532,7 +1532,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][] 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 users AS u JOIN usertags AS tg ON tg.tenant_id=u.tenant_id AND tg.userid=u.id " + + "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 { @@ -1552,7 +1552,7 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][] 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 " + - "FROM topics AS t JOIN topictags AS tg ON t.tenant_id=tg.tenant_id AND t.name=tg.topic " + + "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 " if len(allReq) > 0 { @@ -1621,9 +1621,9 @@ func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) { defer cancel() } rows, err := a.db.QueryxContext(ctx, - "SELECT tp.name AS topic FROM topics AS tp LEFT JOIN topictags AS tt ON tp.tenant_id=tt.tenant_id AND tp.name=tt.topic "+ + "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 users AS u LEFT JOIN usertags AS ut ON u.tenant_id=ut.tenant_id AND ut.userid=u.id "+ + "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) if err != nil { return "", err @@ -1658,7 +1658,7 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { } clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} res, err := a.db.ExecContext(ctx, - "INSERT INTO messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO im_messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)", id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content)) if err == nil { lastID, _ := res.LastInsertId() @@ -1687,7 +1687,7 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs var msg t.Message err := a.db.QueryRowxContext(ctx, "SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ - "FROM messages WHERE `from`=? AND client_msg_id=?", + "FROM im_messages WHERE `from`=? AND client_msg_id=?", decodeUID(from), clientMsgID).StructScan(&msg) if err == sql.ErrNoRows { return nil, nil @@ -1762,11 +1762,15 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, acto return nil, t.ErrDuplicate } _, err = tx.ExecContext(ctx, - "UPDATE messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", + "UPDATE im_messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion) if err != nil { return nil, err } + if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"), + "topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { + return nil, err + } if err = tx.Commit(); err != nil { return nil, err } @@ -1815,11 +1819,15 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor return nil, err } _, err = tx.ExecContext(ctx, - "UPDATE messages SET updatedat=?,version=version+1,head=?,content=? WHERE topic=? AND seqid=? AND version=?", + "UPDATE im_messages SET updatedat=?,version=version+1,head=?,content=? WHERE topic=? AND seqid=? AND version=?", editedAt, head, common.ToJSON(content), topic, seq, expectedVersion) if err != nil { return nil, err } + if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"), + "topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { + return nil, err + } if err = tx.Commit(); err != nil { return nil, err } @@ -1872,7 +1880,7 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op } rows, err := a.db.QueryxContext(ctx, "SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ - "FROM messages AS m LEFT JOIN dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+ + "FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+ "AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.delid=0 AND m.topic=? "+ seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", append([]any{decodeUID(uid), id, topic}, args[4:]...)...) @@ -1936,7 +1944,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu queryArgs = append(queryArgs, limit+1) rows, err := a.db.QueryxContext(ctx, "SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ - "FROM messages AS m LEFT JOIN dellog AS d ON d.topic=m.topic "+ + "FROM im_messages AS m LEFT JOIN im_dellog AS d ON d.topic=m.topic "+ "AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", queryArgs...) if err != nil { @@ -2584,6 +2592,12 @@ func (a *adapter) TenantMeetingCreate(id t.TenantID, uid t.Uid, meeting *t.Meeti return nil, err } } + if err = insertMeetingEventsTx(tx, principalMeetingEvent{ + Actor: uid, MeetingID: meeting.MeetingID, Title: strings.TrimSpace(meeting.Title), + State: t.MeetingStateScheduled, Version: 1, EventType: "meeting.created", + }, participants); err != nil { + return nil, err + } if err = tx.Commit(); err != nil { return nil, err } @@ -2688,7 +2702,16 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string args = append(args, string(encoded)) } args = append(args, meeting.MeetingID, decodeUID(uid), patch.Version) - res, err := a.db.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=? AND organizer_id=? AND version=?`, args...) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if tx != nil { + tx.Rollback() + } + }() + res, err := tx.ExecContext(ctx, `UPDATE im_meetings SET `+strings.Join(sets, ",")+` WHERE meeting_id=? AND organizer_id=? AND version=?`, args...) if err != nil { return nil, err } @@ -2699,6 +2722,20 @@ func (a *adapter) TenantMeetingUpdate(id t.TenantID, uid t.Uid, meetingID string if affected == 0 { return nil, t.ErrInvalidResponse } + participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") + if err != nil { + return nil, err + } + if err = insertMeetingEventsTx(tx, principalMeetingEvent{ + Actor: uid, MeetingID: meeting.MeetingID, Title: effectiveMeetingTitle(meeting.Title, patch.Title), + State: meeting.State, Version: meeting.Version + 1, EventType: "meeting.updated", + }, participantIDs); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } @@ -2729,11 +2766,34 @@ func (a *adapter) TenantMeetingCancel(id t.TenantID, uid t.Uid, meetingID, _ str if meeting.State == t.MeetingStateCanceled { return a.TenantMeetingGet(id, uid, meeting.MeetingID) } - _, err = a.db.ExecContext(ctx, `UPDATE im_meetings SET state=?,version=version+1,canceled_at=?,updated_at=CURRENT_TIMESTAMP(3) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if tx != nil { + tx.Rollback() + } + }() + _, err = tx.ExecContext(ctx, `UPDATE im_meetings SET state=?,version=version+1,canceled_at=?,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND organizer_id=?`, t.MeetingStateCanceled, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) if err != nil { return nil, err } + participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") + if err != nil { + return nil, err + } + if err = insertMeetingEventsTx(tx, principalMeetingEvent{ + Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: t.MeetingStateCanceled, + Version: meeting.Version + 1, EventType: "meeting.canceled", + }, participantIDs); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } @@ -2765,6 +2825,29 @@ func (a *adapter) TenantMeetingRemind(id t.TenantID, uid t.Uid, meetingID, _ str if err != nil { return nil, err } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if tx != nil { + tx.Rollback() + } + }() + participantIDs := make([]t.Uid, 0, len(participants)) + for _, participant := range participants { + participantIDs = append(participantIDs, participant.UserID) + } + if err = insertMeetingEventsTx(tx, principalMeetingEvent{ + Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State, + Version: meeting.Version, EventType: "meeting.reminded", + }, participantIDs); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + tx = nil return participants, nil } @@ -2795,7 +2878,16 @@ func (a *adapter) TenantMeetingRespond(id t.TenantID, uid t.Uid, meetingID, resp if meeting.Version != version { return nil, t.ErrInvalidResponse } - res, err := a.db.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=?,updated_at=CURRENT_TIMESTAMP(3) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if tx != nil { + tx.Rollback() + } + }() + res, err := tx.ExecContext(ctx, `UPDATE im_meeting_participants SET response=?,response_at=?,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND user_id=?`, response, time.Now().UTC().Round(time.Millisecond), meeting.MeetingID, decodeUID(uid)) if err != nil { return nil, err @@ -2807,12 +2899,92 @@ func (a *adapter) TenantMeetingRespond(id t.TenantID, uid t.Uid, meetingID, resp if affected == 0 { return nil, t.ErrPermissionDenied } - if _, err = a.db.ExecContext(ctx, `UPDATE im_meetings SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND version=?`, meeting.MeetingID, version); err != nil { + res, err = tx.ExecContext(ctx, `UPDATE im_meetings SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE meeting_id=? AND version=?`, meeting.MeetingID, version) + if err != nil { return nil, err } + if affected, err = res.RowsAffected(); err != nil { + return nil, err + } + if affected == 0 { + return nil, t.ErrInvalidResponse + } + participantIDs, err := meetingParticipantIDsTx(ctx, tx, meeting.MeetingID, "") + if err != nil { + return nil, err + } + if err = insertMeetingEventsTx(tx, principalMeetingEvent{ + Actor: uid, MeetingID: meeting.MeetingID, Title: meeting.Title, State: meeting.State, + Version: meeting.Version + 1, EventType: "meeting.response.updated", + }, participantIDs); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + tx = nil return a.TenantMeetingGet(id, uid, meeting.MeetingID) } +type principalMeetingEvent struct { + Actor t.Uid + MeetingID string + Title string + State string + Version int + EventType string +} + +func effectiveMeetingTitle(current string, patch *string) string { + if patch == nil { + return current + } + return strings.TrimSpace(*patch) +} + +func meetingParticipantIDsTx(ctx context.Context, tx *sqlx.Tx, meetingID, response string) ([]t.Uid, error) { + query := `SELECT user_id FROM im_meeting_participants WHERE meeting_id=?` + args := []any{meetingID} + if response != "" { + query += ` AND response=?` + args = append(args, response) + } + rows, err := tx.QueryxContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + participants := make([]t.Uid, 0, 8) + for rows.Next() { + var userID int64 + if err = rows.Scan(&userID); err != nil { + return nil, err + } + participants = append(participants, store.EncodeUid(userID)) + } + return participants, rows.Err() +} + +func insertMeetingEventsTx(tx *sqlx.Tx, event principalMeetingEvent, participants []t.Uid) error { + for _, participant := range participants { + if participant.IsZero() { + continue + } + eventID := t.StableEventID("meeting", event.MeetingID, event.Version, event.EventType, participant.UserId()) + if participant != event.Actor { + if err := insertSystemNotificationTx(tx, participant, t.StableEventID(eventID, "notification"), + event.EventType, "会议通知", event.Title, t.KVMap{"meeting_id": event.MeetingID}); err != nil { + return err + } + } + if err := insertBusinessEventTx(tx, eventID, "user", participant.UserId(), event.Actor, event.EventType, + map[string]any{"meeting_id": event.MeetingID, "version": event.Version, "state": event.State}); err != nil { + return err + } + } + return nil +} + func (a *adapter) meetingByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.Meeting, error) { row := struct { MeetingID string `db:"meeting_id"` @@ -3890,6 +4062,57 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush return token, nil } +func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, 0, err + } + if tenantAdp != a { + return tenantAdp.TenantDevicePushTokenGetAll(id, uids...) + } + } + decoded := make([]any, 0, len(uids)) + for _, uid := range uids { + if !uid.IsZero() { + decoded = append(decoded, decodeUID(uid)) + } + } + if len(decoded) == 0 { + return map[t.Uid][]t.DeviceDef{}, 0, nil + } + query, args, err := sqlx.In(`SELECT user_id,token,platform,updated_at FROM im_device_push_tokens + WHERE provider IN ('fcm','firebase') AND user_id IN (?)`, decoded) + if err != nil { + return nil, 0, err + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, a.db.Rebind(query), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + result := make(map[t.Uid][]t.DeviceDef) + count := 0 + for rows.Next() { + var userID int64 + var token, platform string + var updatedAt time.Time + if err = rows.Scan(&userID, &token, &platform, &updatedAt); err != nil { + return nil, 0, err + } + uid := store.EncodeUid(userID) + result[uid] = append(result[uid], t.DeviceDef{ + TenantID: id, DeviceId: token, Platform: strings.ToLower(strings.TrimSpace(platform)), LastSeen: updatedAt, + }) + count++ + } + return result, count, rows.Err() +} + func (a *adapter) TenantRiskChallengeCreate(id t.TenantID, challenge *t.RiskChallenge) (*t.RiskChallenge, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -4362,7 +4585,7 @@ func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t. }{} err := sqlx.GetContext(ctx, q, &row, "SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ - "FROM messages WHERE topic=? AND seqid=?"+suffix, + "FROM im_messages WHERE topic=? AND seqid=?"+suffix, topic, seq) if err != nil { return nil, err @@ -4659,7 +4882,7 @@ func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid defer cancel() } rows, err := a.db.QueryxContext(ctx, - "SELECT tenant_id,topic,deletedfor,delid,low,hi FROM dellog WHERE tenant_id=? AND topic=? AND delid BETWEEN ? AND ? "+ + "SELECT tenant_id,topic,deletedfor,delid,low,hi FROM im_dellog WHERE tenant_id=? AND topic=? AND delid BETWEEN ? AND ? "+ "AND (deletedfor=0 OR deletedfor=?) ORDER BY delid LIMIT ?", id, topic, lower, upper, decodeUID(uid), limit) if err != nil { return nil, err @@ -4727,10 +4950,10 @@ func (a *adapter) TenantDeviceUpsert(id t.TenantID, uid t.Uid, dev *t.DeviceDef) tx.Rollback() } }() - if _, err = tx.Exec("DELETE FROM devices WHERE tenant_id=? AND hash=?", id, hash); err != nil { + if _, err = tx.Exec("DELETE FROM im_devices WHERE tenant_id=? AND hash=?", id, hash); err != nil { return err } - if _, err = tx.Exec("INSERT INTO devices(tenant_id,userid,hash,deviceid,platform,lastseen,lang) VALUES(?,?,?,?,?,?,?)", + if _, err = tx.Exec("INSERT INTO im_devices(tenant_id,userid,hash,deviceid,platform,lastseen,lang) VALUES(?,?,?,?,?,?,?)", id, decodeUID(uid), hash, dev.DeviceId, dev.Platform, dev.LastSeen, dev.Lang); err != nil { return err } @@ -4769,7 +4992,7 @@ func (a *adapter) TenantDeviceGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][] if len(decoded) == 0 { return map[t.Uid][]t.DeviceDef{}, 0, nil } - q, args, _ := sqlx.In("SELECT tenant_id,userid,deviceid,platform,lastseen,lang FROM devices WHERE tenant_id=? AND userid IN (?)", id, decoded) + q, args, _ := sqlx.In("SELECT tenant_id,userid,deviceid,platform,lastseen,lang FROM im_devices WHERE tenant_id=? AND userid IN (?)", id, decoded) ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -4813,7 +5036,7 @@ func (a *adapter) TenantDeviceDelete(id t.TenantID, uid t.Uid, deviceID string) if a.tenantScopedDB { return a.DeviceDelete(uid, deviceID) } - q := "DELETE FROM devices WHERE tenant_id=? AND userid=?" + q := "DELETE FROM im_devices WHERE tenant_id=? AND userid=?" args := []any{id, decodeUID(uid)} if deviceID != "" { q += " AND hash=?" @@ -4853,7 +5076,7 @@ func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error { defer cancel() } _, err = a.db.ExecContext(ctx, - "INSERT INTO fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO im_fileuploads(tenant_id,id,createdat,updatedat,userid,status,mimetype,size,etag,location) VALUES(?,?,?,?,?,?,?,?,?,?)", id, decodeUID(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) return err } @@ -4886,12 +5109,12 @@ func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success b }() now := t.TimeNow() if success { - _, err = tx.Exec("UPDATE fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?", + _, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?", now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid())) fd.Status = t.UploadCompleted fd.Size = size } else { - _, err = tx.Exec("DELETE FROM fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid())) + _, err = tx.Exec("DELETE FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid())) fd.Status = t.UploadFailed fd.Size = 0 } @@ -4926,7 +5149,7 @@ func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) { var fd t.FileDef err = a.db.GetContext(ctx, &fd, "SELECT tenant_id,id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ - "FROM fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID)) + "FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fileID)) if err == sql.ErrNoRows { return nil, nil } @@ -4962,7 +5185,7 @@ func (a *adapter) TenantFileDeleteUnused(id t.TenantID, olderThan time.Time, lim tx.Rollback() } }() - query := "SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id WHERE fu.tenant_id=? AND fml.id IS NULL" + query := "SELECT fu.id,fu.location FROM im_fileuploads AS fu LEFT JOIN im_filemsglinks AS fml ON fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id WHERE fu.tenant_id=? AND fml.id IS NULL" args := []any{id} if !olderThan.IsZero() { query += " AND fu.updatedat 0 { - q, args, _ := sqlx.In("DELETE FROM fileuploads WHERE tenant_id=? AND id IN (?)", id, ids) + q, args, _ := sqlx.In("DELETE FROM im_fileuploads WHERE tenant_id=? AND id IN (?)", id, ids) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return nil, err } @@ -5052,11 +5275,11 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID, } }() if msgID.IsZero() { - if _, err = tx.Exec("DELETE FROM filemsglinks WHERE tenant_id=? AND "+linkBy+"=?", id, linkID); err != nil { + if _, err = tx.Exec("DELETE FROM im_filemsglinks WHERE tenant_id=? AND "+linkBy+"=?", id, linkID); err != nil { return err } } - sqlq := "INSERT INTO filemsglinks(tenant_id,createdat,fileid," + linkBy + ") VALUES (?,?,?,?)" + sqlq := "INSERT INTO im_filemsglinks(tenant_id,createdat,fileid," + linkBy + ") VALUES (?,?,?,?)" _, err = tx.Exec(sqlq+strings.Repeat(",(?,?,?,?)", len(fids)-1), args...) if err != nil { return err @@ -5085,31 +5308,31 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) } var allowed int err = a.db.GetContext(ctx, &allowed, ` - SELECT COUNT(*) FROM fileuploads AS fu + SELECT COUNT(*) FROM im_fileuploads AS fu WHERE fu.tenant_id=? AND fu.id=? AND fu.status=? AND ( fu.userid=? OR EXISTS ( - SELECT 1 FROM filemsglinks AS fml + SELECT 1 FROM im_filemsglinks AS fml WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=? ) OR EXISTS ( - SELECT 1 FROM filemsglinks AS fml - INNER JOIN subscriptions AS s + SELECT 1 FROM im_filemsglinks AS fml + INNER JOIN im_subscriptions AS s ON s.tenant_id=fml.tenant_id AND s.topic=fml.topic AND s.userid=? WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.topic IS NOT NULL AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 ) OR EXISTS ( - SELECT 1 FROM filemsglinks AS fml - INNER JOIN messages AS m + SELECT 1 FROM im_filemsglinks AS fml + INNER JOIN im_messages AS m ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid - INNER JOIN subscriptions AS s + INNER JOIN im_subscriptions AS s ON s.tenant_id=m.tenant_id AND s.topic=m.topic AND s.userid=? WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.msgid IS NOT NULL AND m.deletedat IS NULL AND m.recalled_at IS NULL AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 AND NOT EXISTS ( - SELECT 1 FROM dellog AS d + SELECT 1 FROM im_dellog AS d WHERE d.tenant_id=m.tenant_id AND d.topic=m.topic AND d.deletedfor=? AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid) ) @@ -5984,19 +6207,38 @@ func (a *adapter) TenantFriendRequestCreate(id t.TenantID, req *t.FriendRequest) if err := validTenant(id); err != nil || req == nil || req.TenantID != id || req.ApplicantID.IsZero() || req.TargetID.IsZero() { return nil, t.ErrMalformed } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, "INSERT INTO im_friend_requests(request_id,applicant_id,target_id,message,source,source_topic,status,idempotency_key,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", req.RequestID, decodeUID(req.ApplicantID), decodeUID(req.TargetID), req.Message, req.Source, req.SourceTopic, req.Status, req.IdempotencyKey, req.ExpiresAt, req.CreatedAt, req.UpdatedAt) if err != nil { if isDupe(err) && req.IdempotencyKey != "" { + tx.Rollback() return a.friendRequestByIdempotency(ctx, id, req.ApplicantID, req.IdempotencyKey) } return nil, err } + eventType := "friend.request.created" + eventID := t.StableEventID("relation", req.RequestID, eventType, req.TargetID.UserId()) + if err = insertSystemNotificationTx(tx, req.TargetID, t.StableEventID(eventID, "notification"), eventType, + "新的好友申请", req.Message, map[string]any{"user_id": req.ApplicantID.UserId()}); err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, eventID, "user", req.TargetID.UserId(), req.ApplicantID, eventType, + map[string]any{"user_id": req.ApplicantID.UserId()}); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } return a.friendRequestByRequestID(ctx, id, req.RequestID) } @@ -6047,7 +6289,7 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s args = append(args, limit+1) query := `SELECT fr.*,u.public,u.trusted FROM im_friend_requests AS fr - JOIN users AS u ON u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id) + JOIN im_users AS u ON u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id) WHERE ` + strings.Join(where, " AND ") + ` ORDER BY fr.id DESC LIMIT ?` args = append([]any{decodedUID}, args...) @@ -6297,11 +6539,25 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act if !isDupe(err) { return nil, err } - if _, err = tx.ExecContext(ctx, "UPDATE topics SET updatedat=?,touchedat=?,state=? WHERE name=?", + if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE name=?", handledAt, handledAt, t.StateOK, topic); err != nil { return nil, err } } + eventType := "friend.request." + t.FriendRequestAccepted + applicantEventID := t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted, eventType, applicant.UserId()) + if err = insertSystemNotificationTx(tx, applicant, t.StableEventID(applicantEventID, "notification"), eventType, + "好友申请已处理", t.FriendRequestAccepted, map[string]any{"user_id": actor.UserId()}); err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, applicantEventID, "user", applicant.UserId(), actor, eventType, + map[string]any{"user_id": actor.UserId()}); err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, t.StableEventID("relation", row.RequestID+":"+t.FriendRequestAccepted+":self", eventType, actor.UserId()), + "user", actor.UserId(), actor, eventType, map[string]any{"user_id": actor.UserId()}); err != nil { + return nil, err + } if err = tx.Commit(); err != nil { return nil, err } @@ -6335,11 +6591,33 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac if req.Status != t.FriendRequestPending { return req, nil } - if _, err = a.db.ExecContext(ctx, + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, "UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE request_id=? AND status=?", status, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil { return nil, err } + eventType := "friend.request." + status + eventID := t.StableEventID("relation", req.RequestID+":"+status, eventType, req.ApplicantID.UserId()) + if err = insertSystemNotificationTx(tx, req.ApplicantID, t.StableEventID(eventID, "notification"), eventType, + "好友申请已处理", status, map[string]any{"user_id": actor.UserId()}); err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, eventID, "user", req.ApplicantID.UserId(), actor, eventType, + map[string]any{"user_id": actor.UserId()}); err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, t.StableEventID("relation", req.RequestID+":"+status+":self", eventType, actor.UserId()), + "user", actor.UserId(), actor, eventType, map[string]any{"user_id": actor.UserId()}); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } return a.friendRequestByRequestID(ctx, id, requestID) } @@ -6417,7 +6695,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 users AS u ON u.id=fs.peer_id + JOIN im_users AS u ON u.id=fs.peer_id WHERE `+strings.Join(where, " AND ")+` ORDER BY fs.id ASC LIMIT ?`, args...) if err != nil { @@ -6643,7 +6921,7 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str } rows, err := a.db.QueryxContext(ctx, `SELECT b.*,u.public,u.trusted FROM im_user_blocks AS b - LEFT JOIN users AS u ON u.id=b.blocked_id + LEFT JOIN im_users AS u ON u.id=b.blocked_id WHERE `+strings.Join(where, " AND ")+` ORDER BY b.blocked_id DESC LIMIT ?`, args...) if err != nil { @@ -6770,32 +7048,39 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc where = append(where, "u.id>?") args = append(args, parsed) } - join := "" + joins := []string{"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id"} + addMethod := "account" switch searchType { case "", "account": like := "%" + queryText + "%" - join = "LEFT JOIN usertags AS ut ON ut.userid=u.id" + 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)) case "exact_account": - join = "LEFT JOIN usertags AS ut ON ut.userid=u.id" + 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)) case "phone": + addMethod = "phone" where = append(where, `EXISTS ( - SELECT 1 FROM credentials AS c + SELECT 1 FROM im_credentials AS c WHERE c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true )`) args = append(args, queryText) default: return nil, "", t.ErrMalformed } + where = append(where, `(EXISTS ( + SELECT 1 FROM im_friendships AS privacy_friend + WHERE privacy_friend.user_id=? AND privacy_friend.peer_id=u.id AND privacy_friend.status=? + ) OR COALESCE(JSON_UNQUOTE(JSON_EXTRACT(privacy.add_me_methods, ?)), 'true')='true')`) + args = append(args, decodeUID(viewer), t.RelationshipFriend, "$."+addMethod) args = append(args, limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* FROM users AS u `+join+` + rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* 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 dbe4b4a..9560c25 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/jmoiron/sqlx" + "github.com/tinode/chat/server/db/common" "github.com/tinode/chat/server/store" t "github.com/tinode/chat/server/store/types" ) @@ -130,11 +132,16 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t where += " AND version=?" args = append(args, patch.Version) } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - res, err := a.db.ExecContext(ctx, "UPDATE im_group_settings SET "+strings.Join(set, ",")+" WHERE "+where, args...) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, "UPDATE im_group_settings SET "+strings.Join(set, ",")+" WHERE "+where, args...) if err != nil { return nil, err } @@ -144,6 +151,12 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t } return nil, t.ErrNotFound } + if err = insertGroupEventTx(tx, topic, t.ZeroUid, "group.settings.updated", map[string]any{"topic": topic}); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } return a.groupSettingsByTopic(ctx, topic) } @@ -310,11 +323,16 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid where += " AND version=?" args = append(args, patch.Version) } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - res, err := a.db.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...) if err != nil { return nil, err } @@ -324,7 +342,15 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid } return nil, t.ErrNotFound } - _ = a.recountGroupMembers(ctx, topic) + if err = recountGroupMembersTx(tx, topic); err != nil { + return nil, err + } + if err = insertGroupEventTx(tx, topic, uid, "group.member.updated", map[string]any{"user_id": uid.UserId()}); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid)) } @@ -379,16 +405,29 @@ func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRe if req.IdempotencyKey == "" { req.IdempotencyKey = req.RequestID } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, req.RequestID, req.Topic, decodeUID(req.ApplicantUserID), nullableUID(req.InviterUserID), req.Source, req.QRTokenHash, req.Message, req.Status, req.RejectReason, req.ExpiresAt, req.IdempotencyKey, req.Version, now, now) if err != nil { return nil, err } + if err = insertGroupEventTx(tx, req.Topic, req.ApplicantUserID, "group.join_request.created", map[string]any{ + "request_id": req.RequestID, "applicant_user_id": req.ApplicantUserID.UserId(), + }); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE request_id=?", req.RequestID) } @@ -439,36 +478,72 @@ func (a *adapter) TenantGroupJoinRequestList(id t.TenantID, topic, status, curso return items, next, rows.Err() } -func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID string, actor t.Uid, action, rejectReason string, version int, now time.Time) (*t.GroupJoinRequest, error) { +func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID string, actor t.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*t.GroupJoinRequest, error) { if tenantAdp, err := a.routeGroupTenant(id); err != nil { return nil, err } else if tenantAdp != a { - return tenantAdp.TenantGroupJoinRequestResolve(id, topic, requestID, actor, action, rejectReason, version, now) + return tenantAdp.TenantGroupJoinRequestResolve(id, topic, requestID, actor, action, rejectReason, version, memberPrivate, now) } status := t.GroupJoinRequestStatusRejected if action == "accept" { status = t.GroupJoinRequestStatusAccepted } - args := []any{status, rejectReason, decodeUID(actor), now, now, topic, requestID, t.GroupJoinRequestStatusPending} - where := "topic=? AND request_id=? AND status=?" - if version > 0 { - where += " AND version=?" - args = append(args, version) - } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - res, err := a.db.ExecContext(ctx, "UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 WHERE "+where, args...) + tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } - if affected, _ := res.RowsAffected(); affected == 0 { - if version > 0 { - return nil, t.ErrInvalidResponse - } + defer tx.Rollback() + var current t.GroupJoinRequest + err = tx.QueryRowxContext(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=? FOR UPDATE", topic, requestID).StructScan(¤t) + if err == sql.ErrNoRows { return nil, t.ErrNotFound } + if err != nil { + return nil, err + } + scanGroupJoinRequest(¤t) + if current.Status != t.GroupJoinRequestStatusPending { + return nil, t.ErrInvalidResponse + } + if version > 0 && current.Version != version { + return nil, t.ErrInvalidResponse + } + if action == "accept" { + sub := &t.Subscription{TenantID: id, User: current.ApplicantUserID.String(), Topic: topic, + ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate} + sub.InitTimes() + if err = createSubscription(tx, sub, false); err != nil { + return nil, err + } + member := &t.GroupMember{Topic: topic, UserID: current.ApplicantUserID, Role: t.GroupMemberRoleMember, + MemberStatus: t.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: actor, JoinedAt: now, + Version: 1, CreatedAt: now, UpdatedAt: now} + if err = upsertGroupMemberTx(tx, member); err != nil { + return nil, err + } + } + if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 + WHERE topic=? AND request_id=? AND status=?`, status, rejectReason, decodeUID(actor), now, now, topic, requestID, t.GroupJoinRequestStatusPending); err != nil { + return nil, err + } + if err = recountGroupMembersTx(tx, topic); err != nil { + return nil, err + } + if err = updateGroupTopicSubCountTx(tx, topic); err != nil { + return nil, err + } + if err = insertGroupEventTx(tx, topic, actor, "group.join_request."+status, map[string]any{ + "request_id": requestID, "applicant_user_id": current.ApplicantUserID.UserId(), "status": status, + }); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=?", topic, requestID) } @@ -530,6 +605,11 @@ func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnounce VALUES(?,?,?,?,?,?,?,?,?,?)`, ann.AnnouncementID, ann.Topic, decodeUID(ann.AuthorUserID), ann.Content, t.GroupAnnouncementStatusCurrent, version, now, ann.IdempotencyKey, now, now); err != nil { return nil, err } + if err = insertGroupEventTx(tx, ann.Topic, ann.AuthorUserID, "group.announcement.updated", map[string]any{ + "announcement_id": ann.AnnouncementID, "version": version, + }); err != nil { + return nil, err + } if err = tx.Commit(); err != nil { return nil, err } @@ -607,17 +687,30 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr if mute.Status == "" { mute.Status = t.GroupMuteStatusActive } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + 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) 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) if err != nil { return nil, err } + if err = insertGroupEventTx(tx, mute.Topic, mute.MutedBy, "group.mute.updated", map[string]any{ + "user_id": mute.UserID.UserId(), "muted": true, "expires_at": mute.ExpiresAt, + }); err != nil { + return nil, err + } + 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)) } @@ -627,11 +720,16 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor } else if tenantAdp != a { return tenantAdp.TenantGroupMuteDelete(id, topic, uid, actor, now) } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - res, err := a.db.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND status=?", + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND status=?", t.GroupMuteStatusRevoked, decodeUID(actor), now, now, topic, decodeUID(uid), t.GroupMuteStatusActive) if err != nil { return err @@ -639,7 +737,397 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor if affected, _ := res.RowsAffected(); affected == 0 { return t.ErrNotFound } - return nil + if err = insertGroupEventTx(tx, topic, actor, "group.mute.updated", map[string]any{ + "user_id": uid.UserId(), "muted": false, + }); err != nil { + return err + } + return tx.Commit() +} + +func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any, + settings *t.GroupSettings, members []t.Uid, memberPrivate any, now time.Time) error { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return err + } else if tenantAdp != a { + return tenantAdp.TenantGroupCreate(id, topic, owner, ownerPrivate, settings, members, memberPrivate, now) + } + if topic == nil || settings == nil || topic.Id == "" || settings.Topic != topic.Id || owner.IsZero() { + return t.ErrMalformed + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err = a.topicCreate(tx, topic); err != nil { + return err + } + ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(), + Topic: topic.Id, ModeGiven: t.ModeCFull, ModeWant: topic.GetAccess(owner), Private: ownerPrivate} + ownerSub.InitTimes() + if err = createSubscription(tx, ownerSub, false); err != nil { + return err + } + settings.MemberCount = 1 + if err = insertGroupSettingsTx(tx, settings); err != nil { + return err + } + ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner, + MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now, + Version: 1, CreatedAt: now, UpdatedAt: now} + if err = upsertGroupMemberTx(tx, ownerMember); err != nil { + return err + } + seen := map[t.Uid]bool{owner: true} + for _, uid := range members { + if uid.IsZero() || seen[uid] { + continue + } + seen[uid] = true + sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: topic.Id, + ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate} + sub.InitTimes() + if err = createSubscription(tx, sub, false); err != nil { + return err + } + member := &t.GroupMember{Topic: topic.Id, UserID: uid, Role: t.GroupMemberRoleMember, + MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now, + Version: 1, CreatedAt: now, UpdatedAt: now} + if err = upsertGroupMemberTx(tx, member); err != nil { + return err + } + } + if err = recountGroupMembersTx(tx, topic.Id); err != nil { + return err + } + if err = updateGroupTopicSubCountTx(tx, topic.Id); err != nil { + return err + } + if err = insertGroupEventTx(tx, topic.Id, owner, "group.created", map[string]any{ + "owner_user_id": owner.UserId(), "member_count": len(seen), + }); err != nil { + return err + } + return tx.Commit() +} + +func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter, target t.Uid, + message, idempotencyKey string, private any, now time.Time) (bool, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return false, err + } else if tenantAdp != a { + return tenantAdp.TenantGroupInviteMember(id, topic, inviter, target, message, idempotencyKey, private, now) + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var deletedAt sql.NullTime + err = tx.QueryRowContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=? FOR UPDATE", topic, decodeUID(target)).Scan(&deletedAt) + if err != nil && err != sql.ErrNoRows { + return false, err + } + if err == nil && !deletedAt.Valid { + return false, nil + } + sub := &t.Subscription{TenantID: id, User: target.String(), Topic: topic, + ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: private} + sub.InitTimes() + if err = createSubscription(tx, sub, false); err != nil { + return false, err + } + invitationID := "ginv_" + store.Store.GetUidString() + if idempotencyKey == "" { + idempotencyKey = invitationID + } else { + idempotencyKey += ":" + target.UserId() + } + if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations + (invitation_id,topic,inviter_user_id,target_user_id,message,status,result,idempotency_key,expires_at,handled_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, invitationID, topic, decodeUID(inviter), decodeUID(target), message, + t.GroupInvitationStatusJoined, "joined", idempotencyKey, now.Add(7*24*time.Hour), now, now, now); err != nil { + return false, err + } + member := &t.GroupMember{Topic: topic, UserID: target, Role: t.GroupMemberRoleMember, + MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inviter, JoinedAt: now, + Version: 1, CreatedAt: now, UpdatedAt: now} + if err = upsertGroupMemberTx(tx, member); err != nil { + return false, err + } + if err = recountGroupMembersTx(tx, topic); err != nil { + return false, err + } + if err = updateGroupTopicSubCountTx(tx, topic); err != nil { + return false, err + } + if err = insertGroupEventTx(tx, topic, inviter, "group.member.joined", map[string]any{ + "user_id": target.UserId(), "source": "invitation", + }); err != nil { + return false, err + } + if err = tx.Commit(); err != nil { + return false, err + } + return true, nil +} + +func (a *adapter) TenantGroupLeave(id t.TenantID, topic string, uid t.Uid, now time.Time) error { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return err + } else if tenantAdp != a { + return tenantAdp.TenantGroupLeave(id, topic, uid, now) + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decodeUID(uid)) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrNotFound + } + if _, err = tx.ExecContext(ctx, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=? + WHERE topic=? AND user_id=? AND member_status=?`, t.GroupMemberStatusLeft, now, now, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil { + return err + } + if err = recountGroupMembersTx(tx, topic); err != nil { + return err + } + if err = updateGroupTopicSubCountTx(tx, topic); err != nil { + return err + } + if err = insertGroupEventTx(tx, topic, uid, "group.member.left", map[string]any{"user_id": uid.UserId()}); err != nil { + return err + } + return tx.Commit() +} + +func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, target t.Uid, + role string, modeGiven t.AccessMode, now time.Time) error { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return err + } else if tenantAdp != a { + return tenantAdp.TenantGroupRoleUpdate(id, topic, actor, target, role, modeGiven, now) + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", + modeGiven.String(), now, topic, decodeUID(target)) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrNotFound + } + res, err = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?", + role, now, topic, decodeUID(target), t.GroupMemberStatusActive) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrNotFound + } + if err = insertGroupEventTx(tx, topic, actor, "group.role.updated", map[string]any{ + "user_id": target.UserId(), "role": role, + }); err != nil { + return err + } + return tx.Commit() +} + +func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid, + version int, now time.Time) error { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return err + } else if tenantAdp != a { + return tenantAdp.TenantGroupOwnerTransfer(id, topic, previousOwner, newOwner, version, now) + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + updates := []struct { + uid t.Uid + mode t.AccessMode + role string + }{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}} + for _, update := range updates { + res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", + update.mode.String(), now, topic, decodeUID(update.uid)) + if execErr != nil { + return execErr + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrNotFound + } + res, execErr = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?", + update.role, now, topic, decodeUID(update.uid), t.GroupMemberStatusActive) + if execErr != nil { + return execErr + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrNotFound + } + } + if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET owner=?,updatedat=? WHERE name=?", decodeUID(newOwner), now, topic); err != nil { + return err + } + args := []any{decodeUID(newOwner), now, topic} + query := "UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?" + if version > 0 { + query += " AND version=?" + args = append(args, version) + } + res, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return t.ErrInvalidResponse + } + if err = insertGroupEventTx(tx, topic, previousOwner, "group.owner.transferred", map[string]any{ + "previous_owner": previousOwner.UserId(), "owner": newOwner.UserId(), + }); err != nil { + return err + } + return tx.Commit() +} + +func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, admin bool, now time.Time) (bool, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return false, err + } else if tenantAdp != a { + return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now) + } + if admin { + return true, nil + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var muteAll, memberMuted bool + err := a.db.QueryRowContext(ctx, `SELECT + COALESCE((SELECT mute_all FROM im_group_settings WHERE topic=? AND group_status=? LIMIT 1),0), + EXISTS(SELECT 1 FROM im_group_mutes WHERE topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`, + topic, t.GroupStatusActive, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted) + if err != nil { + return false, err + } + return !muteAll && !memberMuted, nil +} + +func (a *adapter) TenantGroupEventListPending(id t.TenantID, limit int, now time.Time) ([]t.GroupEvent, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return nil, err + } else if tenantAdp != a { + return tenantAdp.TenantGroupEventListPending(id, limit, now) + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_group_events_outbox + WHERE status='pending' AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, now, limit) + if err != nil { + return nil, err + } + defer rows.Close() + events := make([]t.GroupEvent, 0, limit) + for rows.Next() { + var event t.GroupEvent + if err = rows.StructScan(&event); err != nil { + return nil, err + } + event.ActorUserID = store.EncodeUid(event.ActorRaw) + events = append(events, event) + } + return events, rows.Err() +} + +func (a *adapter) TenantGroupEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return err + } else if tenantAdp != a { + return tenantAdp.TenantGroupEventMarkDelivered(id, eventID, now) + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + _, err := a.db.ExecContext(ctx, `UPDATE im_group_events_outbox SET status='delivered',attempts=attempts+1, + delivered_at=?,updated_at=? WHERE event_id=? AND status='pending'`, now, now, eventID) + return err +} + +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, + 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) + return err +} + +func upsertGroupMemberTx(tx *sqlx.Tx, member *t.GroupMember) error { + _, err := tx.Exec(`INSERT INTO im_group_members + (topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname), + pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source), + joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`, + member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned, + member.MuteNotifications, member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version, + member.CreatedAt, member.UpdatedAt) + return err +} + +func recountGroupMembersTx(tx *sqlx.Tx, topic string) error { + _, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members + WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic) + return err +} + +func updateGroupTopicSubCountTx(tx *sqlx.Tx, topic string) error { + _, err := tx.Exec(`UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions + WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?`, topic, t.GrpToChn(topic), topic) + return err +} + +func insertGroupEventTx(tx *sqlx.Tx, topic string, actor t.Uid, eventType string, payload any) error { + _, err := tx.Exec(`INSERT INTO im_group_events_outbox(event_id,topic,actor_user_id,event_type,payload,status) + VALUES(?,?,?,?,?,'pending')`, "gev_"+store.Store.GetUidString(), topic, decodeUID(actor), eventType, common.ToJSON(payload)) + return err } func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) { diff --git a/server/db/mysql/tenant_infrastructure.go b/server/db/mysql/tenant_infrastructure.go new file mode 100644 index 0000000..f2cb03f --- /dev/null +++ b/server/db/mysql/tenant_infrastructure.go @@ -0,0 +1,253 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/jmoiron/sqlx" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" +) + +func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) { + if !a.tenantScopedDB { + return a.routedTenantAdapter(id) + } + if err := validTenant(id); err != nil { + return nil, err + } + return a, nil +} + +func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) { + tenantAdp, err := a.infrastructureAdapter(id) + if err != nil { + return nil, false, err + } + if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" { + return nil, false, t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantHTTPIdempotencyBegin(id, record) + } + now := t.TimeNow() + if record.ExpiresAt.IsZero() { + record.ExpiresAt = now.Add(24 * time.Hour) + } + _, err = a.db.Exec(`INSERT INTO im_http_idempotency + (user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, decodeUID(record.UserID), record.Method, record.Route, record.Key, + record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now) + if err == nil { + record.State = t.HTTPIdempotencyProcessing + record.CreatedAt = now + record.UpdatedAt = now + return record, true, nil + } + if !isDupe(err) { + return nil, false, err + } + existing, err := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key) + if err == nil && !existing.ExpiresAt.After(now) { + res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency + WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`, + decodeUID(record.UserID), record.Method, record.Route, record.Key, now) + if deleteErr != nil { + return nil, false, deleteErr + } + if deleted, _ := res.RowsAffected(); deleted > 0 { + return a.TenantHTTPIdempotencyBegin(id, record) + } + current, getErr := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key) + return current, false, getErr + } + return existing, false, err +} + +func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error { + tenantAdp, err := a.infrastructureAdapter(id) + if err != nil { + return err + } + if record == nil || record.UserID.IsZero() { + return t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantHTTPIdempotencyComplete(id, record) + } + now := t.TimeNow() + res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=? + WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`, + t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, decodeUID(record.UserID), + record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing) + if err != nil { + return err + } + if count, _ := res.RowsAffected(); count == 0 { + return t.ErrNotFound + } + return nil +} + +func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error { + tenantAdp, err := a.infrastructureAdapter(id) + if err != nil { + return err + } + if record == nil || record.UserID.IsZero() { + return t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantHTTPIdempotencyAbort(id, record) + } + _, err = a.db.Exec(`DELETE FROM im_http_idempotency + WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`, + decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing) + return err +} + +func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) { + row := struct { + UserID int64 `db:"user_id"` + Method string + Route string + Key string `db:"idempotency_key"` + BodyHash string `db:"body_hash"` + State string + ResponseStatus int `db:"response_status"` + ResponseBody []byte `db:"response_body"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + ExpiresAt time.Time `db:"expires_at"` + }{} + err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at + FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=?`, decodeUID(uid), method, route, key) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route, + Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus, + ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil +} + +func (a *adapter) TenantBusinessEventCreate(id t.TenantID, event *t.BusinessEvent) error { + tenantAdp, err := a.infrastructureAdapter(id) + if err != nil { + return err + } + if event == nil || event.EventID == "" || event.Target == "" || event.EventType == "" || + (event.TargetType != "user" && event.TargetType != "topic") { + return t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantBusinessEventCreate(id, event) + } + now := t.TimeNow() + _, err = a.db.Exec(`INSERT INTO im_business_events_outbox + (event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, event.EventID, + event.TargetType, event.Target, decodeUID(event.ActorUserID), event.EventType, event.Payload, + t.BusinessEventPending, now, now) + return err +} + +func (a *adapter) TenantBusinessEventListPending(id t.TenantID, limit int, now time.Time) ([]t.BusinessEvent, error) { + tenantAdp, err := a.infrastructureAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantBusinessEventListPending(id, limit, now) + } + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := a.db.Queryx(`SELECT * FROM im_business_events_outbox + WHERE status=? AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, t.BusinessEventPending, now, limit) + if err != nil { + return nil, err + } + defer rows.Close() + events := make([]t.BusinessEvent, 0, limit) + for rows.Next() { + var row struct { + ID int64 + EventID string `db:"event_id"` + TargetType string `db:"target_type"` + Target string + ActorUserID int64 `db:"actor_user_id"` + EventType string `db:"event_type"` + Payload []byte + Status string + Attempts int + NextRetryAt *time.Time `db:"next_retry_at"` + DeliveredAt *time.Time `db:"delivered_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + if err = rows.StructScan(&row); err != nil { + return nil, err + } + events = append(events, t.BusinessEvent{ID: row.ID, EventID: row.EventID, TargetType: row.TargetType, + Target: row.Target, ActorUserID: store.EncodeUid(row.ActorUserID), EventType: row.EventType, + Payload: row.Payload, Status: row.Status, Attempts: row.Attempts, NextRetryAt: row.NextRetryAt, + DeliveredAt: row.DeliveredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}) + } + return events, rows.Err() +} + +func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error { + tenantAdp, err := a.infrastructureAdapter(id) + if err != nil { + return err + } + if eventID == "" { + return t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantBusinessEventMarkDelivered(id, eventID, now) + } + res, err := a.db.Exec(`UPDATE im_business_events_outbox SET status=?,delivered_at=?,attempts=attempts+1,updated_at=? + WHERE event_id=? AND status=?`, t.BusinessEventDelivered, now, now, eventID, t.BusinessEventPending) + if err != nil { + return err + } + if count, _ := res.RowsAffected(); count == 0 { + return t.ErrNotFound + } + return nil +} + +func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + now := t.TimeNow() + _, err = tx.Exec(`INSERT INTO im_business_events_outbox + (event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventID, targetType, + target, decodeUID(actor), eventType, encoded, t.BusinessEventPending, now, now) + return err +} + +func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error { + encoded, err := json.Marshal(action) + if err != nil { + return err + } + now := t.TimeNow() + _, err = tx.Exec(`INSERT INTO im_system_notifications + (user_id,event_id,event_type,title,summary,action,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title), + summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, decodeUID(uid), eventID, + eventType, title, summary, encoded, now, now) + return err +} diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go index e662342..58cd697 100644 --- a/server/db/mysql/tests/mysql_test.go +++ b/server/db/mysql/tests/mysql_test.go @@ -15,6 +15,7 @@ import ( "log" "os" "reflect" + "strings" "testing" "time" @@ -48,17 +49,126 @@ var testData *test_data.TestData var dummyUid1 = types.Uid(12345) var dummyUid2 = types.Uid(54321) +func TestCreateDb(t *testing.T) { + if err := adp.CreateDb(config.Reset); err != nil { + t.Fatal(err) + } + // Saved db is closed, get a fresh one. + db = adp.GetTestDB().(*sqlx.DB) +} + +func TestGroupBusinessTablesCreated(t *testing.T) { + tables := []string{ + "im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests", + "im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes", + "im_group_files", "im_group_admin_logs", "im_group_idempotency", "im_group_events_outbox", + } + args := make([]any, len(tables)) + for i, table := range tables { + args[i] = table + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",") + + var count int + query := "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")" + if err := db.Get(&count, query, args...); err != nil { + t.Fatal(err) + } + if count != len(tables) { + t.Fatalf("group business table count: got %d, want %d", count, len(tables)) + } + + query = "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'" + if err := db.Get(&count, query, args...); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("tenant business tables contain %d tenant_id columns", count) + } +} + +func TestHTTPInfrastructureTablesCreated(t *testing.T) { + tables := []string{"im_http_idempotency", "im_business_events_outbox"} + args := make([]any, len(tables)) + for i, table := range tables { + args[i] = table + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",") + + var count int + query := "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")" + if err := db.Get(&count, query, args...); err != nil { + t.Fatal(err) + } + if count != len(tables) { + t.Fatalf("HTTP infrastructure table count: got %d, want %d", count, len(tables)) + } + + query = "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'" + if err := db.Get(&count, query, args...); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("HTTP infrastructure tables contain %d tenant_id columns", count) + } +} + func TestTenantAuthIsolation(t *testing.T) { tenantAdp, ok := adp.(adapter.TenantAuthAdapter) if !ok { t.Fatal("MySQL adapter does not implement TenantAuthAdapter") } + var routingConfig struct { + Enabled bool `json:"tenant_db_routing"` + } + if err := json.Unmarshal(config.Adapters[adp.GetName()], &routingConfig); err != nil { + t.Fatal(err) + } + if !routingConfig.Enabled { + t.Skip("tenant_db_routing is disabled in the MySQL test configuration") + } + router, ok := adp.(adapter.TenantRoutedAdapter) + if !ok { + t.Fatal("MySQL adapter does not implement TenantRoutedAdapter") + } now := types.TimeNow() suffix := fmt.Sprintf("%d", now.UnixNano()) - createTenant := func(code string) types.TenantID { - res, err := db.Exec("INSERT INTO im_tenant(code,name,state,created_by,updated_by) VALUES(?,?,?,?,?)", - code+suffix, code, types.TenantStateActive, 0, 0) + dbNames := []string{"im_tenant_auth_a_" + suffix, "im_tenant_auth_b_" + suffix} + quoteIdent := func(value string) string { + return "`" + strings.ReplaceAll(value, "`", "``") + "`" + } + var baseDB string + if err := db.Get(&baseDB, "SELECT DATABASE()"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM im_tenant WHERE code IN (?,?)", "test-im_auth-a-"+suffix, "test-im_auth-b-"+suffix) + for _, dbName := range dbNames { + db.Exec("DROP DATABASE IF EXISTS " + quoteIdent(dbName)) + } + }) + + for _, dbName := range dbNames { + if _, err := db.Exec("CREATE DATABASE " + quoteIdent(dbName) + + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { + t.Fatal(err) + } + for _, table := range []string{"im_users", "im_auth", "im_credentials"} { + query := "CREATE TABLE " + quoteIdent(dbName) + "." + quoteIdent(table) + + " LIKE " + quoteIdent(baseDB) + "." + quoteIdent(table) + if _, err := db.Exec(query); err != nil { + t.Fatal(err) + } + } + } + createTenant := func(code, dbName string) types.TenantID { + res, err := db.Exec("INSERT INTO im_tenant(code,name,db_name,state,created_by,updated_by) VALUES(?,?,?,?,?,?)", + code+suffix, code, dbName, types.TenantStateActive, 0, 0) if err != nil { t.Fatal(err) } @@ -68,44 +178,41 @@ func TestTenantAuthIsolation(t *testing.T) { } return types.TenantID(id) } + tenantA := createTenant("test-im_auth-a-", dbNames[0]) + tenantB := createTenant("test-im_auth-b-", dbNames[1]) + businessA, _, err := router.TenantAdapterFor(tenantA) + if err != nil { + t.Fatal(err) + } + if reflect.ValueOf(businessA).Pointer() == reflect.ValueOf(adp).Pointer() { + t.Skip("tenant_db_routing is disabled in the MySQL test configuration") + } - tenantA := createTenant("test-auth-a-") - tenantB := createTenant("test-auth-b-") userAID := now.UnixNano() & 0x3fffffffffffffff userBID := userAID + 1 - t.Cleanup(func() { - db.Exec("DELETE FROM credentials WHERE tenant_id IN (?,?)", tenantA, tenantB) - db.Exec("DELETE FROM auth WHERE tenant_id IN (?,?)", tenantA, tenantB) - db.Exec("DELETE FROM users WHERE tenant_id IN (?,?)", tenantA, tenantB) - db.Exec("DELETE FROM im_tenant WHERE id IN (?,?)", tenantA, tenantB) - }) - - for tenantID, userID := range map[types.TenantID]int64{tenantA: userAID, tenantB: userBID} { - _, err := db.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) "+ - "VALUES(?,?,?,?,?,?,?,?,?)", userID, tenantID, now, now, types.StateOK, `{}`, nil, nil, nil) - if err != nil { + 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 { t.Fatal(err) } - _, err = db.Exec("INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?,?)", - tenantID, "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")) - if err != nil { + if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)", + "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil { t.Fatal(err) } - _, err = db.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+ - "VALUES(?,?,?,?,?,?,?,?,?)", tenantID, now, now, "tel", "13800000000", "tel:13800000000", - userID, "", true) - if err != nil { + if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ + "VALUES(?,?,?,?,?,?,?,?)", now, now, "tel", "13800000000", "tel:13800000000", userID, "", true); err != nil { t.Fatal(err) } } uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice") if err != nil || uidA != store.EncodeUid(userAID) { - t.Fatalf("tenant A auth lookup: uid=%v err=%v", uidA, err) + t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err) } uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice") if err != nil || uidB != store.EncodeUid(userBID) { - t.Fatalf("tenant B auth lookup: uid=%v err=%v", uidB, err) + t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err) } credA, err := tenantAdp.TenantUserGetByCred(tenantA, "tel", "13800000000") if err != nil || credA != uidA { @@ -118,18 +225,10 @@ func TestTenantAuthIsolation(t *testing.T) { if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed", auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound { - t.Fatalf("cross-tenant auth update: got %v, want %v", err, types.ErrNotFound) + t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound) } } -func TestCreateDb(t *testing.T) { - if err := adp.CreateDb(config.Reset); err != nil { - t.Fatal(err) - } - // Saved db is closed, get a fresh one. - db = adp.GetTestDB().(*sqlx.DB) -} - // ================== Create tests ================================ func TestUserCreate(t *testing.T) { for _, user := range testData.Users { @@ -142,12 +241,12 @@ func TestUserCreate(t *testing.T) { if err := db.Ping(); err != nil { logs.Err.Println("Database ping failed:", err) } - err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + err := db.QueryRow("SELECT COUNT(*) FROM im_users").Scan(&count) if err != nil { t.Error(err) } if count == 0 { - t.Error("No users created!") + t.Error("No im_users created!") } } @@ -245,7 +344,7 @@ func TestTopicCreateP2P(t *testing.T) { } var got types.Subscription - err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?", + err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=? AND userid=?", testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt, &got.UpdatedAt, &got.DeletedAt, &got.User, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &got.ModeWant, &got.ModeGiven, &got.Private) if err != nil { @@ -346,7 +445,7 @@ func TestUserGetAll(t *testing.T) { t.Fatal(err) } if len(got) > 0 { - t.Error("result users should be zero length, got", len(got)) + t.Error("result im_users should be zero length, got", len(got)) } got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id)) @@ -698,7 +797,7 @@ func TestUserUpdate(t *testing.T) { UpdatedAt time.Time CreatedAt time.Time } - err = db.QueryRow("SELECT useragent, updatedat, createdat FROM users WHERE id=?", decodeUid(testData.Users[0].Id)). + err = db.QueryRow("SELECT useragent, updatedat, createdat FROM im_users WHERE id=?", decodeUid(testData.Users[0].Id)). Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt) if err != nil { t.Fatal(err) @@ -775,7 +874,7 @@ func TestCredFail(t *testing.T) { UpdatedAt time.Time CreatedAt time.Time } - err = db.QueryRow("SELECT retries, updatedat, createdat FROM credentials WHERE userid=? AND method=? AND value=?", + err = db.QueryRow("SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=? AND method=? AND value=?", decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt) if err != nil { t.Fatal(err) @@ -800,7 +899,7 @@ func TestCredConfirm(t *testing.T) { CreatedAt time.Time Done bool } - err = db.QueryRow("SELECT updatedat, createdat, done FROM credentials WHERE userid=? AND method=? AND value=?", + err = db.QueryRow("SELECT updatedat, createdat, done FROM im_credentials WHERE userid=? AND method=? AND value=?", decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done) if err != nil { t.Fatal(err) @@ -822,7 +921,7 @@ func TestAuthUpdRecord(t *testing.T) { t.Fatal(err) } var got []byte - err = db.QueryRow("SELECT secret FROM auth WHERE uname=?", rec.Unique).Scan(&got) + err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got) if err != nil { t.Fatal(err) } @@ -839,12 +938,12 @@ func TestAuthUpdRecord(t *testing.T) { } // Test if old ID deleted var count int - err = db.QueryRow("SELECT COUNT(*) FROM auth WHERE uname=?", rec.Unique).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count) if err != nil { t.Fatal(err) } if count != 0 { - t.Error("Old auth record not deleted") + t.Error("Old im_auth record not deleted") } } @@ -863,7 +962,7 @@ func TestTopicUpdateOnMessage(t *testing.T) { TouchedAt time.Time SeqId int } - err = db.QueryRow("SELECT touchedat, seqid FROM topics WHERE name=?", testData.Topics[2].Id). + err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id). Scan(&got.TouchedAt, &got.SeqId) if err != nil { t.Fatal(err) @@ -883,7 +982,7 @@ func TestTopicUpdate(t *testing.T) { t.Fatal(err) } var got time.Time - err = db.QueryRow("SELECT updatedat FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got) + err = db.QueryRow("SELECT updatedat FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got) if err != nil { t.Fatal(err) } @@ -898,7 +997,7 @@ func TestTopicOwnerChange(t *testing.T) { t.Fatal(err) } var got int64 - err = db.QueryRow("SELECT owner FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got) + err = db.QueryRow("SELECT owner FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got) if err != nil { t.Fatal(err) } @@ -917,7 +1016,7 @@ func TestSubsUpdate(t *testing.T) { t.Fatal(err) } var got time.Time - err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? AND userid=?", + err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? AND userid=?", testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got) if err != nil { t.Fatal(err) @@ -930,7 +1029,7 @@ func TestSubsUpdate(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? LIMIT 1", + err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? LIMIT 1", testData.Topics[1].Id).Scan(&got) if err != nil { t.Fatal(err) @@ -946,7 +1045,7 @@ func TestSubsDelete(t *testing.T) { t.Fatal(err) } var deletedat sql.NullTime - err = db.QueryRow("SELECT deletedat FROM subscriptions WHERE topic=? AND userid=?", + err = db.QueryRow("SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?", testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat) if err != nil { t.Fatal(err) @@ -965,7 +1064,7 @@ func TestDeviceUpsert(t *testing.T) { DeviceId string Platform string } - err = db.QueryRow("SELECT deviceid, platform FROM devices WHERE userid=? LIMIT 1", + err = db.QueryRow("SELECT deviceid, platform FROM im_devices WHERE userid=? LIMIT 1", decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform) if err != nil { t.Fatal(err) @@ -980,7 +1079,7 @@ func TestDeviceUpsert(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?", + err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?", decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform) if err != nil { t.Fatal(err) @@ -994,7 +1093,7 @@ func TestDeviceUpsert(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?", + err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?", decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&got.Platform) if err != nil { t.Fatal(err) @@ -1030,7 +1129,7 @@ func TestMessageAttachments(t *testing.T) { } // Check if attachments were linked (this would require checking filemsglinks table) var count int - if err = db.QueryRow("SELECT COUNT(*) FROM filemsglinks WHERE msgid=?", + if err = db.QueryRow("SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=?", types.ParseUid(testData.Msgs[1].Id)).Scan(&count); err != nil { t.Fatal(err) } @@ -1066,7 +1165,7 @@ func TestDeviceDelete(t *testing.T) { t.Fatal(err) } var count int - err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[1].Id).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[1].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1078,7 +1177,7 @@ func TestDeviceDelete(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[2].Id).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[2].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1154,7 +1253,7 @@ func TestCredDel(t *testing.T) { t.Fatal(err) } var count int - err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count) if err != nil { t.Fatal(err) } @@ -1166,7 +1265,7 @@ func TestCredDel(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE userid=?", testData.Users[1].Id).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE userid=?", testData.Users[1].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1218,13 +1317,13 @@ func TestMessageDeleteList(t *testing.T) { // Check messages in dellog var count int - err = db.QueryRow("SELECT COUNT(*) FROM dellog WHERE topic=? AND deletedfor=?", + err = db.QueryRow("SELECT COUNT(*) FROM im_dellog WHERE topic=? AND deletedfor=?", toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count) if err != nil { t.Fatal(err) } if count == 0 { - t.Error("No dellog entries created") + t.Error("No im_dellog entries created") } // Hard delete test @@ -1244,7 +1343,7 @@ func TestMessageDeleteList(t *testing.T) { } // Check if messages content was cleared - err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=? AND content IS NOT NULL", + err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=? AND content IS NOT NULL", toDel.Topic).Scan(&count) if err != nil { t.Fatal(err) @@ -1257,7 +1356,7 @@ func TestMessageDeleteList(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=?", testData.Topics[0].Id).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=?", testData.Topics[0].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1272,7 +1371,7 @@ func TestTopicDelete(t *testing.T) { t.Fatal(err) } var state int - err = db.QueryRow("SELECT state FROM topics WHERE name=?", testData.Topics[1].Id).Scan(&state) + err = db.QueryRow("SELECT state FROM im_topics WHERE name=?", testData.Topics[1].Id).Scan(&state) if err != nil { t.Fatal(err) } @@ -1286,7 +1385,7 @@ func TestTopicDelete(t *testing.T) { } var count int - err = db.QueryRow("SELECT COUNT(*) FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1311,7 +1410,7 @@ func TestUserDelete(t *testing.T) { t.Fatal(err) } var state int - err = db.QueryRow("SELECT state FROM users WHERE id=?", + err = db.QueryRow("SELECT state FROM im_users WHERE id=?", decodeUid(testData.Users[0].Id)).Scan(&state) if err != nil { t.Fatal(err) @@ -1325,7 +1424,7 @@ func TestUserDelete(t *testing.T) { t.Fatal(err) } var count int - err = db.QueryRow("SELECT COUNT(*) FROM users WHERE id=?", testData.Users[1].Id).Scan(&count) + err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=?", testData.Users[1].Id).Scan(&count) if err != nil { t.Fatal(err) } diff --git a/server/db/postgres/adapter.go b/server/db/postgres/adapter.go index 190a935..0f1997d 100644 --- a/server/db/postgres/adapter.go +++ b/server/db/postgres/adapter.go @@ -219,7 +219,7 @@ func (a *adapter) GetDbVersion() (int, error) { defer cancel() } var vers string - err := a.db.QueryRow(ctx, "SELECT value FROM kvmeta WHERE key='version'").Scan(&vers) + err := a.db.QueryRow(ctx, "SELECT value FROM im_kvmeta WHERE key='version'").Scan(&vers) if err != nil { if isMissingDb(err) || isMissingTable(err) || err == pgx.ErrNoRows { err = errors.New("Database not initialized") @@ -238,7 +238,7 @@ func (a *adapter) updateDbVersion(v int) error { defer cancel() } a.version = -1 - if _, err := a.db.Exec(ctx, `UPDATE kvmeta SET "value"=$1 WHERE "key"='version'`, strconv.Itoa(v)); err != nil { + if _, err := a.db.Exec(ctx, `UPDATE im_kvmeta SET "value"=$1 WHERE "key"='version'`, strconv.Itoa(v)); err != nil { return err } return nil @@ -346,9 +346,9 @@ func (a *adapter) CreateDb(reset bool) error { } }() - // Indexed users. + // Indexed im_users. if _, err := tx.Exec(ctx, - `CREATE TABLE users( + `CREATE TABLE im_users( id BIGINT NOT NULL, createdat TIMESTAMP(3) NOT NULL, updatedat TIMESTAMP(3) NOT NULL, @@ -362,28 +362,28 @@ func (a *adapter) CreateDb(reset bool) error { tags JSON, PRIMARY KEY(id) ); - CREATE INDEX users_state_stateat ON users(state, stateat); - CREATE INDEX users_lastseen_updatedat ON users(lastseen, updatedat);`); err != nil { + CREATE INDEX users_state_stateat ON im_users(state, stateat); + CREATE INDEX users_lastseen_updatedat ON im_users(lastseen, updatedat);`); err != nil { return err } // Indexed user tags. if _, err = tx.Exec(ctx, - `CREATE TABLE usertags( + `CREATE TABLE im_usertags( id SERIAL NOT NULL, userid BIGINT NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id) + FOREIGN KEY(userid) REFERENCES im_users(id) ); - CREATE INDEX usertags_tag ON usertags(tag); - CREATE UNIQUE INDEX usertags_userid_tag ON usertags(userid, tag);`); err != nil { + CREATE INDEX usertags_tag ON im_usertags(tag); + CREATE UNIQUE INDEX usertags_userid_tag ON im_usertags(userid, tag);`); err != nil { return err } - // Indexed devices. Normalized into a separate table. + // Indexed im_devices. Normalized into a separate table. if _, err = tx.Exec(ctx, - `CREATE TABLE devices( + `CREATE TABLE im_devices( id SERIAL NOT NULL, userid BIGINT NOT NULL, hash CHAR(16) NOT NULL, @@ -392,15 +392,15 @@ func (a *adapter) CreateDb(reset bool) error { lastseen TIMESTAMP NOT NULL, lang VARCHAR(8), PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id) + FOREIGN KEY(userid) REFERENCES im_users(id) ); - CREATE UNIQUE INDEX devices_hash ON devices(hash);`); err != nil { + CREATE UNIQUE INDEX devices_hash ON im_devices(hash);`); err != nil { return err } // Authentication records for the basic authentication scheme. if _, err = tx.Exec(ctx, - `CREATE TABLE auth( + `CREATE TABLE im_auth( id SERIAL NOT NULL, uname VARCHAR(32) NOT NULL, userid BIGINT NOT NULL, @@ -409,16 +409,16 @@ func (a *adapter) CreateDb(reset bool) error { secret VARCHAR(255) NOT NULL, expires TIMESTAMP, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id) + FOREIGN KEY(userid) REFERENCES im_users(id) ); - CREATE UNIQUE INDEX auth_userid_scheme ON auth(userid, scheme); - CREATE UNIQUE INDEX auth_uname ON auth(uname);`); err != nil { + CREATE UNIQUE INDEX auth_userid_scheme ON im_auth(userid, scheme); + CREATE UNIQUE INDEX auth_uname ON im_auth(uname);`); err != nil { return err } // Topics if _, err = tx.Exec(ctx, - `CREATE TABLE topics( + `CREATE TABLE im_topics( id SERIAL NOT NULL, createdat TIMESTAMP(3) NOT NULL, updatedat TIMESTAMP(3) NOT NULL, @@ -438,10 +438,10 @@ func (a *adapter) CreateDb(reset bool) error { aux JSON, PRIMARY KEY(id) ); - CREATE UNIQUE INDEX topics_name ON topics(name); - CREATE INDEX topics_owner ON topics(owner); - CREATE INDEX topics_state_stateat ON topics(state, stateat); - CREATE INDEX topics_name_state_seqid ON topics(name, state, seqid);`); err != nil { + CREATE UNIQUE INDEX topics_name ON im_topics(name); + CREATE INDEX topics_owner ON im_topics(owner); + CREATE INDEX topics_state_stateat ON im_topics(state, stateat); + CREATE INDEX topics_name_state_seqid ON im_topics(name, state, seqid);`); err != nil { return err } @@ -452,21 +452,21 @@ func (a *adapter) CreateDb(reset bool) error { // Indexed topic tags. if _, err = tx.Exec(ctx, - `CREATE TABLE topictags( + `CREATE TABLE im_topictags( id SERIAL NOT NULL, topic VARCHAR(25) NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name) + FOREIGN KEY(topic) REFERENCES im_topics(name) ); - CREATE INDEX topictags_tag ON topictags(tag); - CREATE UNIQUE INDEX topictags_topic_tag ON topictags(topic, tag);`); err != nil { + CREATE INDEX topictags_tag ON im_topictags(tag); + CREATE UNIQUE INDEX topictags_topic_tag ON im_topictags(topic, tag);`); err != nil { return err } // Subscriptions if _, err = tx.Exec(ctx, - `CREATE TABLE subscriptions( + `CREATE TABLE im_subscriptions( id SERIAL NOT NULL, createdat TIMESTAMP(3) NOT NULL, updatedat TIMESTAMP(3) NOT NULL, @@ -480,18 +480,18 @@ func (a *adapter) CreateDb(reset bool) error { modegiven VARCHAR(8), private JSON, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id) + FOREIGN KEY(userid) REFERENCES im_users(id) ); - CREATE UNIQUE INDEX subscriptions_topic_userid ON subscriptions(topic, userid); - CREATE INDEX subscriptions_topic ON subscriptions(topic); - CREATE INDEX subscriptions_deletedat ON subscriptions(deletedat); - CREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat);`); err != nil { + CREATE UNIQUE INDEX subscriptions_topic_userid ON im_subscriptions(topic, userid); + CREATE INDEX subscriptions_topic ON im_subscriptions(topic); + CREATE INDEX subscriptions_deletedat ON im_subscriptions(deletedat); + CREATE INDEX subscriptions_userid_topic_deletedat ON im_subscriptions(userid, topic, deletedat);`); err != nil { return err } // Messages if _, err = tx.Exec(ctx, - `CREATE TABLE messages( + `CREATE TABLE im_messages( id SERIAL NOT NULL, createdat TIMESTAMP(3) NOT NULL, updatedat TIMESTAMP(3) NOT NULL, @@ -503,15 +503,15 @@ func (a *adapter) CreateDb(reset bool) error { head JSON, content JSON, PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name) + FOREIGN KEY(topic) REFERENCES im_topics(name) ); - CREATE UNIQUE INDEX messages_topic_seqid ON messages(topic, seqid);`); err != nil { + CREATE UNIQUE INDEX messages_topic_seqid ON im_messages(topic, seqid);`); err != nil { return err } // Deletion log if _, err = tx.Exec(ctx, - `CREATE TABLE dellog( + `CREATE TABLE im_dellog( id SERIAL NOT NULL, topic VARCHAR(25) NOT NULL, deletedfor BIGINT NOT NULL DEFAULT 0, @@ -519,17 +519,17 @@ func (a *adapter) CreateDb(reset bool) error { low INT NOT NULL, hi INT NOT NULL, PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name) + FOREIGN KEY(topic) REFERENCES im_topics(name) ); - CREATE INDEX dellog_topic_delid_deletedfor ON dellog(topic,delid,deletedfor); - CREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic,deletedfor,low,hi); - CREATE INDEX dellog_deletedfor ON dellog(deletedfor);`); err != nil { + CREATE INDEX dellog_topic_delid_deletedfor ON im_dellog(topic,delid,deletedfor); + CREATE INDEX dellog_topic_deletedfor_low_hi ON im_dellog(topic,deletedfor,low,hi); + CREATE INDEX dellog_deletedfor ON im_dellog(deletedfor);`); err != nil { return err } - // User credentials + // User im_credentials if _, err = tx.Exec(ctx, - `CREATE TABLE credentials( + `CREATE TABLE im_credentials( id SERIAL NOT NULL, createdat TIMESTAMP(3) NOT NULL, updatedat TIMESTAMP(3) NOT NULL, @@ -542,16 +542,16 @@ func (a *adapter) CreateDb(reset bool) error { done BOOLEAN NOT NULL DEFAULT FALSE, retries INT NOT NULL DEFAULT 0, PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id) + FOREIGN KEY(userid) REFERENCES im_users(id) ); - CREATE UNIQUE INDEX credentials_uniqueness ON credentials(synthetic);`); err != nil { + CREATE UNIQUE INDEX credentials_uniqueness ON im_credentials(synthetic);`); err != nil { return err } // Records of uploaded files. // Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion. if _, err = tx.Exec(ctx, - `CREATE TABLE fileuploads( + `CREATE TABLE im_fileuploads( id BIGINT NOT NULL, createdat TIMESTAMP(3) NOT NULL, updatedat TIMESTAMP(3) NOT NULL, @@ -563,13 +563,13 @@ func (a *adapter) CreateDb(reset bool) error { location VARCHAR(2048) NOT NULL, PRIMARY KEY(id) ); - CREATE INDEX fileuploads_status ON fileuploads(status);`); err != nil { + CREATE INDEX fileuploads_status ON im_fileuploads(status);`); err != nil { return err } - // Links between uploaded files and the topics, users or messages they are attached to. + // Links between uploaded files and the im_topics, im_users or im_messages they are attached to. if _, err = tx.Exec(ctx, - `CREATE TABLE filemsglinks( + `CREATE TABLE im_filemsglinks( id SERIAL NOT NULL, createdat TIMESTAMP(3) NOT NULL, fileid BIGINT NOT NULL, @@ -577,25 +577,25 @@ func (a *adapter) CreateDb(reset bool) error { topic VARCHAR(25), userid BIGINT, PRIMARY KEY(id), - FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE, - FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE, - FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE + FOREIGN KEY(fileid) REFERENCES im_fileuploads(id) ON DELETE CASCADE, + FOREIGN KEY(msgid) REFERENCES im_messages(id) ON DELETE CASCADE, + FOREIGN KEY(topic) REFERENCES im_topics(name) ON DELETE CASCADE, + FOREIGN KEY(userid) REFERENCES im_users(id) ON DELETE CASCADE );`); err != nil { return err } if _, err = tx.Exec(ctx, - `CREATE TABLE kvmeta( + `CREATE TABLE im_kvmeta( "key" VARCHAR(64) NOT NULL, createdat TIMESTAMP(3), "value" TEXT, PRIMARY KEY("key") ); - CREATE INDEX kvmeta_createdat_key ON kvmeta(createdat, "key");`); err != nil { + CREATE INDEX kvmeta_createdat_key ON im_kvmeta(createdat, "key");`); err != nil { return err } - if _, err = tx.Exec(ctx, `INSERT INTO kvmeta("key", "value") VALUES($1, $2)`, "version", strconv.Itoa(adpVersion)); err != nil { + if _, err = tx.Exec(ctx, `INSERT INTO im_kvmeta("key", "value") VALUES($1, $2)`, "version", strconv.Itoa(adpVersion)); err != nil { return err } @@ -625,26 +625,26 @@ func (a *adapter) UpgradeDb() error { // Perform database upgrade from version 112 to version 113. // Index for deleting unvalidated accounts. - if _, err := a.db.Exec(ctx, "CREATE INDEX users_lastseen_updatedat ON users(lastseen,updatedat)"); err != nil { + if _, err := a.db.Exec(ctx, "CREATE INDEX users_lastseen_updatedat ON im_users(lastseen,updatedat)"); err != nil { return err } - // Allow lnger kvmeta keys. - if _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ALTER COLUMN "key" TYPE VARCHAR(64)`); err != nil { + // Allow lnger im_kvmeta keys. + if _, err := a.db.Exec(ctx, `ALTER TABLE im_kvmeta ALTER COLUMN "key" TYPE VARCHAR(64)`); err != nil { return err } - if _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ALTER COLUMN "key" SET NOT NULL`); err != nil { + if _, err := a.db.Exec(ctx, `ALTER TABLE im_kvmeta ALTER COLUMN "key" SET NOT NULL`); err != nil { return err } - // Add timestamp to kvmeta. - if _, err := a.db.Exec(ctx, `ALTER TABLE kvmeta ADD COLUMN createdat TIMESTAMP(3)`); err != nil { + // Add timestamp to im_kvmeta. + if _, err := a.db.Exec(ctx, `ALTER TABLE im_kvmeta ADD COLUMN createdat TIMESTAMP(3)`); err != nil { return err } // Add compound index on the new field and key (could be searched by key prefix). - if _, err := a.db.Exec(ctx, `CREATE INDEX kvmeta_createdat_key ON kvmeta(createdat, "key")`); err != nil { + if _, err := a.db.Exec(ctx, `CREATE INDEX kvmeta_createdat_key ON im_kvmeta(createdat, "key")`); err != nil { return err } @@ -656,11 +656,11 @@ func (a *adapter) UpgradeDb() error { if a.version == 113 { // Perform database upgrade from version 113 to version 114. - if _, err := a.db.Exec(ctx, "ALTER TABLE topics ADD COLUMN aux JSON"); err != nil { + if _, err := a.db.Exec(ctx, "ALTER TABLE im_topics ADD COLUMN aux JSON"); err != nil { return err } - if _, err := a.db.Exec(ctx, "ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128)"); err != nil { + if _, err := a.db.Exec(ctx, "ALTER TABLE im_fileuploads ADD COLUMN etag VARCHAR(128)"); err != nil { return err } @@ -672,13 +672,13 @@ func (a *adapter) UpgradeDb() error { if a.version == 114 { // Perform database upgrade from version 114 to version 115. - // Find relevant subscriptions for given users efficiently, and use the join key too. - if _, err := a.db.Exec(ctx, "CREATE INDEX idx_subs_user_topic_del ON subscriptions(userid, topic, deletedat)"); err != nil { + // Find relevant im_subscriptions for given im_users efficiently, and use the join key too. + if _, err := a.db.Exec(ctx, "CREATE INDEX idx_subs_user_topic_del ON im_subscriptions(userid, topic, deletedat)"); err != nil { return err } // Optimizes join; state filters; seqid supports the SUM operation. - if _, err := a.db.Exec(ctx, "CREATE INDEX idx_topics_name_state_seqid ON topics(name, state, seqid)"); err != nil { + if _, err := a.db.Exec(ctx, "CREATE INDEX idx_topics_name_state_seqid ON im_topics(name, state, seqid)"); err != nil { return err } @@ -690,8 +690,8 @@ func (a *adapter) UpgradeDb() error { if a.version == 115 { // Perform database upgrade from version 115 to version 116. - // Add subscriber count column to the topics table. - if _, err := a.db.Exec(ctx, "ALTER TABLE topics ADD subcnt INT DEFAULT 0"); err != nil { + // Add subscriber count column to the im_topics table. + if _, err := a.db.Exec(ctx, "ALTER TABLE im_topics ADD subcnt INT DEFAULT 0"); err != nil { return err } @@ -709,7 +709,7 @@ func (a *adapter) UpgradeDb() error { func createSystemTopic(tx pgx.Tx) error { now := t.TimeNow() - query := `INSERT INTO topics(createdat,updatedat,state,touchedat,name,access,public) + query := `INSERT INTO im_topics(createdat,updatedat,state,touchedat,name,access,public) VALUES($1,$2,$3,$4,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')` _, err := tx.Exec(context.Background(), query, now, now, t.StateOK, now) return err @@ -720,7 +720,7 @@ func addTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, return nil } - //addTags(ctx, tx, "usertags", "userid", decoded_uid, add, reset == nil) + //addTags(ctx, tx, "im_usertags", "userid", decoded_uid, add, reset == nil) sql := "INSERT INTO " + table + " (" + keyName + ",tag) VALUES($1,$2)" if ignoreDups { sql += " ON CONFLICT DO NOTHING" @@ -768,7 +768,7 @@ func (a *adapter) UserCreate(user *t.User) error { decoded_uid := store.DecodeUid(user.Uid()) if _, err = tx.Exec(ctx, - "INSERT INTO users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES($1,$2,$3,$4,$5,$6,$7,$8);", + "INSERT INTO im_users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES($1,$2,$3,$4,$5,$6,$7,$8);", decoded_uid, user.CreatedAt, user.UpdatedAt, @@ -781,7 +781,7 @@ func (a *adapter) UserCreate(user *t.User) error { } // Save user's tags to a separate table to make user findable. - if err = addTags(ctx, tx, "usertags", "userid", decoded_uid, user.Tags, false); err != nil { + if err = addTags(ctx, tx, "im_usertags", "userid", decoded_uid, user.Tags, false); err != nil { return err } @@ -801,7 +801,7 @@ func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.L defer cancel() } - if _, err := a.db.Exec(ctx, "INSERT INTO auth(uname,userid,scheme,authLvl,secret,expires) VALUES($1,$2,$3,$4,$5,$6)", + if _, err := a.db.Exec(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES($1,$2,$3,$4,$5,$6)", unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil { if isDupe(err) { return t.ErrDuplicate @@ -817,7 +817,7 @@ func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error { if cancel != nil { defer cancel() } - _, err := a.db.Exec(ctx, "DELETE FROM auth WHERE userid=$1 AND scheme=$2", store.DecodeUid(user), scheme) + _, err := a.db.Exec(ctx, "DELETE FROM im_auth WHERE userid=$1 AND scheme=$2", store.DecodeUid(user), scheme) return err } @@ -828,7 +828,7 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) { defer cancel() } - res, err := a.db.Exec(ctx, "DELETE FROM auth WHERE userid=$1", store.DecodeUid(user)) + res, err := a.db.Exec(ctx, "DELETE FROM im_auth WHERE userid=$1", store.DecodeUid(user)) if err != nil { return 0, err } @@ -837,7 +837,7 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) { return int(count), nil } -// Update user's authentication unique, secret, auth level. +// Update user's authentication unique, secret, im_auth level. func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { @@ -861,7 +861,7 @@ func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.L if cancel != nil { defer cancel() } - sql, args := expandQuery("UPDATE auth SET "+strings.Join(parapg, ",")+" WHERE userid=? AND scheme=?", args...) + sql, args := expandQuery("UPDATE im_auth SET "+strings.Join(parapg, ",")+" WHERE userid=? AND scheme=?", args...) resp, err := a.db.Exec(ctx, sql, args...) if isDupe(err) { return t.ErrDuplicate @@ -889,7 +889,7 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [ if cancel != nil { defer cancel() } - if err := a.db.QueryRow(ctx, "SELECT uname,secret,expires,authlvl FROM auth WHERE userid=$1 AND scheme=$2", + if err := a.db.QueryRow(ctx, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=$1 AND scheme=$2", store.DecodeUid(uid), scheme).Scan( &record.Uname, &record.Secret, &record.Expires, &record.Authlvl); err != nil { if err == pgx.ErrNoRows { @@ -921,7 +921,7 @@ func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, if cancel != nil { defer cancel() } - if err := a.db.QueryRow(ctx, "SELECT userid,secret,expires,authlvl FROM auth WHERE uname=$1", unique).Scan( + if err := a.db.QueryRow(ctx, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=$1", unique).Scan( &record.Userid, &record.Secret, &record.Expires, &record.Authlvl); err != nil { if err == pgx.ErrNoRows { // Nothing found - clear the error @@ -946,7 +946,7 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) { var user t.User var id int64 - row, err := a.db.Query(ctx, "SELECT * FROM users WHERE id=$1 AND state!=$2", store.DecodeUid(uid), t.StateDeleted) + row, err := a.db.Query(ctx, "SELECT * FROM im_users WHERE id=$1 AND state!=$2", store.DecodeUid(uid), t.StateDeleted) if err != nil { return nil, err } @@ -972,13 +972,13 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { uids[i] = store.DecodeUid(id) } - users := []t.User{} + im_users := []t.User{} ctx, cancel := a.getContext() if cancel != nil { defer cancel() } - rows, err := a.db.Query(ctx, "SELECT * FROM users WHERE id = ANY ($1) AND state!=$2", uids, t.StateDeleted) + rows, err := a.db.Query(ctx, "SELECT * FROM im_users WHERE id = ANY ($1) AND state!=$2", uids, t.StateDeleted) if err != nil { return nil, err } @@ -988,26 +988,26 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { var user t.User var id int64 if err = rows.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags); err != nil { - users = nil + im_users = nil break } user.SetUid(store.EncodeUid(id)) - users = append(users, user) + im_users = append(im_users, user) } if err == nil { err = rows.Err() } - return users, err + return im_users, err } // UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted. // TODO: report when the user is not found. func (a *adapter) UserDelete(uid t.Uid, hard bool) error { - query := "SELECT name FROM topics WHERE owner=$1" + query := "SELECT name FROM im_topics WHERE owner=$1" args := []any{store.DecodeUid(uid)} - // In case of hard delete, delete all topics, even those which were + // In case of hard delete, delete all im_topics, even those which were // soft-deleted previsously. if !hard { query += " AND state!=$2" @@ -1038,112 +1038,112 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { decoded_uid := store.DecodeUid(uid) if hard { - // Delete user's devices - // t.ErrNotFound = user has no devices. + // Delete user's im_devices + // t.ErrNotFound = user has no im_devices. if err = deviceDelete(ctx, tx, uid, ""); err != nil && err != t.ErrNotFound { return err } - // Delete user's subscriptions in all topics. + // Delete user's im_subscriptions in all im_topics. if err = subsDelForUser(ctx, tx, decoded_uid, true); err != nil { return err } - // Delete records of messages soft-deleted for the user. - if _, err = tx.Exec(ctx, "DELETE FROM dellog WHERE deletedfor=$1", decoded_uid); err != nil { + // Delete records of im_messages soft-deleted for the user. + if _, err = tx.Exec(ctx, "DELETE FROM im_dellog WHERE deletedfor=$1", decoded_uid); err != nil { return err } - // Can't delete user's messages in all topics because we cannot notify topics of such deletion. - // Just leave the messages there marked as sent by "not found" user. + // Can't delete user's im_messages in all im_topics because we cannot notify im_topics of such deletion. + // Just leave the im_messages there marked as sent by "not found" user. - // Delete topics where the user is the owner. + // Delete im_topics where the user is the owner. if len(ownTopics) > 0 { - // First delete all messages in those topics. - if _, err = tx.Exec(ctx, "DELETE FROM dellog USING topics WHERE topics.name=dellog.topic AND topics.owner=$1", + // First delete all im_messages in those im_topics. + if _, err = tx.Exec(ctx, "DELETE FROM im_dellog USING im_topics WHERE im_topics.name=im_dellog.topic AND im_topics.owner=$1", decoded_uid); err != nil { return err } - // Deletion of messages will cascade to filemsglinks and so to fileuploads. - if _, err = tx.Exec(ctx, "DELETE FROM messages USING topics WHERE topics.name=messages.topic AND topics.owner=$1", + // Deletion of im_messages will cascade to im_filemsglinks and so to im_fileuploads. + if _, err = tx.Exec(ctx, "DELETE FROM im_messages USING im_topics WHERE im_topics.name=im_messages.topic AND im_topics.owner=$1", decoded_uid); err != nil { return err } - // Delete subscriptions for all users where the user is the owner of the topic. - sql, args, _ := sqlx.In("DELETE FROM subscriptions AS s WHERE topic IN (?)", ownTopics) + // Delete im_subscriptions for all im_users where the user is the owner of the topic. + sql, args, _ := sqlx.In("DELETE FROM im_subscriptions AS s WHERE topic IN (?)", ownTopics) if _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil { return err } // Delete topic tags. - if _, err = tx.Exec(ctx, "DELETE FROM topictags USING topics WHERE topics.name=topictags.topic AND topics.owner=$1", + if _, err = tx.Exec(ctx, "DELETE FROM im_topictags USING im_topics WHERE im_topics.name=im_topictags.topic AND im_topics.owner=$1", decoded_uid); err != nil { return err } - // And finally delete the topics. - if _, err = tx.Exec(ctx, "DELETE FROM topics WHERE owner=$1", decoded_uid); err != nil { + // And finally delete the im_topics. + if _, err = tx.Exec(ctx, "DELETE FROM im_topics WHERE owner=$1", decoded_uid); err != nil { return err } } // Delete user's authentication records. - if _, err = tx.Exec(ctx, "DELETE FROM auth WHERE userid=$1", decoded_uid); err != nil { + if _, err = tx.Exec(ctx, "DELETE FROM im_auth WHERE userid=$1", decoded_uid); err != nil { return err } - // Delete all credentials. + // Delete all im_credentials. if err = credDel(ctx, tx, uid, "", ""); err != nil && err != t.ErrNotFound { return err } - if _, err = tx.Exec(ctx, "DELETE FROM usertags WHERE userid=$1", decoded_uid); err != nil { + if _, err = tx.Exec(ctx, "DELETE FROM im_usertags WHERE userid=$1", decoded_uid); err != nil { return err } - if _, err = tx.Exec(ctx, "DELETE FROM users WHERE id=$1", decoded_uid); err != nil { + if _, err = tx.Exec(ctx, "DELETE FROM im_users WHERE id=$1", decoded_uid); err != nil { return err } } else { - // Disable all user's subscriptions. That includes p2p subscriptions. No need to delete them. + // Disable all user's im_subscriptions. That includes p2p im_subscriptions. No need to delete them. if err = subsDelForUser(ctx, tx, decoded_uid, false); err != nil { return err } if len(ownTopics) > 0 { - // Disable all subscriptions to topics where the user is the owner. - sql, args, _ := sqlx.In("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics) + // Disable all im_subscriptions to im_topics where the user is the owner. + sql, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics) if _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil { return err } - // Disable group topics where the user is the owner. - if _, err = tx.Exec(ctx, "UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE owner=$3", + // Disable group im_topics where the user is the owner. + if _, err = tx.Exec(ctx, "UPDATE im_topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE owner=$3", now, t.StateDeleted, decoded_uid); err != nil { return err } } - // Disable p2p topics with the user (p2p topic's owner is 0). - if _, err = tx.Exec(ctx, "UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 "+ - "FROM subscriptions WHERE topics.name=subscriptions.topic "+ - "AND topics.owner=0 AND subscriptions.userid=$3", + // Disable p2p im_topics with the user (p2p topic's owner is 0). + if _, err = tx.Exec(ctx, "UPDATE im_topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 "+ + "FROM im_subscriptions WHERE im_topics.name=im_subscriptions.topic "+ + "AND im_topics.owner=0 AND im_subscriptions.userid=$3", now, t.StateDeleted, decoded_uid); err != nil { return err } // Disable the other user's subscription to a disabled p2p topic. - if _, err = tx.Exec(ctx, "UPDATE subscriptions AS s_one SET updatedat=$1,deletedat=$1 "+ - "FROM subscriptions AS s_two WHERE s_one.topic=s_two.topic "+ + if _, err = tx.Exec(ctx, "UPDATE im_subscriptions AS s_one SET updatedat=$1,deletedat=$1 "+ + "FROM im_subscriptions AS s_two WHERE s_one.topic=s_two.topic "+ "AND s_two.userid=$2 AND s_two.topic LIKE 'p2p%'", now, decoded_uid); err != nil { return err } // Disable user. - if _, err = tx.Exec(ctx, "UPDATE users SET updatedat=$1,state=$2,stateat=$1 WHERE id=$3", + if _, err = tx.Exec(ctx, "UPDATE im_users SET updatedat=$1,state=$2,stateat=$1 WHERE id=$3", now, t.StateDeleted, decoded_uid); err != nil { return err } @@ -1153,7 +1153,7 @@ func (a *adapter) UserDelete(uid t.Uid, hard bool) error { } // topicStateForUser is called by UserUpdate when the update contains state change. -// Soft-deleted topics remain soft-deleted. +// Soft-deleted im_topics remain soft-deleted. func (a *adapter) topicStateForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, now time.Time, update any) error { var err error @@ -1166,22 +1166,22 @@ func (a *adapter) topicStateForUser(ctx context.Context, tx pgx.Tx, decoded_uid now = t.TimeNow() } - // Change state of all topics where the user is the owner. - if _, err = tx.Exec(ctx, "UPDATE topics SET state=$1, stateat=$2 WHERE owner=$3 AND state!=$4", + // Change state of all im_topics where the user is the owner. + if _, err = tx.Exec(ctx, "UPDATE im_topics SET state=$1, stateat=$2 WHERE owner=$3 AND state!=$4", state, now, decoded_uid, t.StateDeleted); err != nil { return err } - // Change state of p2p topics with the user (p2p topic's owner is 0) - if _, err = tx.Exec(ctx, "UPDATE topics SET state=$1, stateat=$2 "+ - "FROM subscriptions WHERE topics.name=subscriptions.topic AND "+ - "topics.owner=0 AND subscriptions.userid=$3 AND topics.state!=$4", + // Change state of p2p im_topics with the user (p2p topic's owner is 0) + if _, err = tx.Exec(ctx, "UPDATE im_topics SET state=$1, stateat=$2 "+ + "FROM im_subscriptions WHERE im_topics.name=im_subscriptions.topic AND "+ + "im_topics.owner=0 AND im_subscriptions.userid=$3 AND im_topics.state!=$4", state, now, decoded_uid, t.StateDeleted); err != nil { return err } // Subscriptions don't need to be updated: - // subscriptions of a disabled user are not disabled and still can be manipulated. + // im_subscriptions of a disabled user are not disabled and still can be manipulated. return nil } @@ -1206,7 +1206,7 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error { cols, args := common.UpdateByMap(update) decoded_uid := store.DecodeUid(uid) args = append(args, decoded_uid) - sql, args := expandQuery("UPDATE users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) + sql, args := expandQuery("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) _, err = tx.Exec(ctx, sql, args...) if err != nil { return err @@ -1223,12 +1223,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(ctx, "DELETE FROM usertags WHERE userid=$1", decoded_uid) + _, err = tx.Exec(ctx, "DELETE FROM im_usertags WHERE userid=$1", decoded_uid) if err != nil { return err } // Now insert new tags - err = addTags(ctx, tx, "usertags", "userid", decoded_uid, tags, false) + err = addTags(ctx, tx, "im_usertags", "userid", decoded_uid, tags, false) if err != nil { return err } @@ -1239,7 +1239,7 @@ func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error { func tempFetchTags(ctx context.Context, tx pgx.Tx, decoded_uid int64) ([]string, error) { var allTags []string - rows, err := tx.Query(ctx, "SELECT tag FROM usertags WHERE userid=$1", decoded_uid) + rows, err := tx.Query(ctx, "SELECT tag FROM im_usertags WHERE userid=$1", decoded_uid) if err != nil { return nil, err } @@ -1274,7 +1274,7 @@ func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]stri if reset != nil { // Delete all tags first if resetting. - _, err = tx.Exec(ctx, "DELETE FROM usertags WHERE userid=$1", decoded_uid) + _, err = tx.Exec(ctx, "DELETE FROM im_usertags WHERE userid=$1", decoded_uid) if err != nil { return nil, err } @@ -1283,19 +1283,19 @@ func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]stri } // Now insert new tags. Ignore duplicates if resetting. - err = addTags(ctx, tx, "usertags", "userid", decoded_uid, add, reset == nil) + err = addTags(ctx, tx, "im_usertags", "userid", decoded_uid, add, reset == nil) if err != nil { return nil, err } // Delete tags. - err = removeTags(ctx, tx, "usertags", "userid", decoded_uid, remove) + err = removeTags(ctx, tx, "im_usertags", "userid", decoded_uid, remove) if err != nil { return nil, err } var allTags []string - rows, err := tx.Query(ctx, "SELECT tag FROM usertags WHERE userid=$1", decoded_uid) + rows, err := tx.Query(ctx, "SELECT tag FROM im_usertags WHERE userid=$1", decoded_uid) if err != nil { return nil, err } @@ -1307,7 +1307,7 @@ func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]stri allTags = append(allTags, tag) } - _, err = tx.Exec(ctx, "UPDATE users SET tags=$1 WHERE id=$2", t.StringSlice(allTags), decoded_uid) + _, err = tx.Exec(ctx, "UPDATE im_users SET tags=$1 WHERE id=$2", t.StringSlice(allTags), decoded_uid) if err != nil { return nil, err } @@ -1322,7 +1322,7 @@ func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) { defer cancel() } var decoded_uid int64 - err := a.db.QueryRow(ctx, "SELECT userid FROM credentials WHERE synthetic=$1", method+":"+value).Scan(&decoded_uid) + err := a.db.QueryRow(ctx, "SELECT userid FROM im_credentials WHERE synthetic=$1", method+":"+value).Scan(&decoded_uid) if err == nil { return store.EncodeUid(decoded_uid), nil } @@ -1334,10 +1334,10 @@ func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) { return t.ZeroUid, err } -// UserUnreadCount returns the total number of unread messages in all topics with +// UserUnreadCount returns the total number of unread im_messages in all im_topics with // the R permission. If read fails, the counts are still returned with the original // user IDs but with the unread count undefined and non-nil error. -// UserUnreadCount does not count unread messages in channels although it should. +// UserUnreadCount does not count unread im_messages in channels although it should. func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) { uids := make([]any, len(ids)) counts := make(map[t.Uid]int, len(ids)) @@ -1353,7 +1353,7 @@ func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) { } // FIXME: support channels. - query, uids := expandQuery("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM topics AS t, subscriptions AS s "+ + query, uids := expandQuery("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM im_topics AS t, im_subscriptions AS s "+ "WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND "+ "POSITION('R' IN s.modewant)>0 AND POSITION('R' IN s.modegiven)>0 GROUP BY s.userid", uids, t.StateDeleted) rows, err := a.db.Query(ctx, query, uids...) @@ -1378,7 +1378,7 @@ func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) { } // UserGetUnvalidated returns a list of uids which have never logged in, have no -// validated credentials and haven't been updated since lastUpdatedBefore. +// validated im_credentials and haven't been updated since lastUpdatedBefore. func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) { var uids []t.Uid @@ -1389,7 +1389,7 @@ func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([] rows, err := a.db.Query(ctx, "SELECT u.id, COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) AS total "+ - "FROM users u LEFT JOIN credentials c ON u.id = c.userid "+ + "FROM im_users u LEFT JOIN im_credentials c ON u.id = c.userid "+ "WHERE u.lastseen IS NULL AND u.updatedat < $1 GROUP BY u.id, u.updatedat "+ "HAVING COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) = 0 ORDER BY u.updatedat ASC LIMIT $2", lastUpdatedBefore, limit) @@ -1416,7 +1416,7 @@ func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([] // ***************************** func (a *adapter) topicCreate(ctx context.Context, tx pgx.Tx, topic *t.Topic) error { - _, err := tx.Exec(ctx, "INSERT INTO topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+ + _, err := tx.Exec(ctx, "INSERT INTO im_topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+ "VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)", topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt, store.DecodeUid(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted), @@ -1426,7 +1426,7 @@ func (a *adapter) topicCreate(ctx context.Context, tx pgx.Tx, topic *t.Topic) er } // Save topic's tags to a separate table to make topic findable. - return addTags(ctx, tx, "topictags", "topic", topic.Id, topic.Tags, false) + return addTags(ctx, tx, "im_topictags", "topic", topic.Id, topic.Tags, false) } // TopicCreate saves topic object to database. @@ -1464,7 +1464,7 @@ func createSubscription(ctx context.Context, tx pgx.Tx, sub *t.Subscription, und log.Println("Error: Failed to create savepoint: ", err2.Error()) } _, err := tx.Exec(ctx, - "INSERT INTO subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ + "INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ "VALUES($1,$2,NULL,$3,$4,$5,$6,$7)", sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) @@ -1474,11 +1474,11 @@ func createSubscription(ctx context.Context, tx pgx.Tx, sub *t.Subscription, und log.Println("Error: Failed to rollback savepoint: ", err2.Error()) } if undelete { - _, err = tx.Exec(ctx, "UPDATE subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+ + _, err = tx.Exec(ctx, "UPDATE im_subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+ "delid=0,recvseqid=0,readseqid=0 WHERE topic=$5 AND userid=$6", sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid) } else { - _, err = tx.Exec(ctx, "UPDATE subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+ + _, err = tx.Exec(ctx, "UPDATE im_subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+ "delid=0,recvseqid=0,readseqid=0,private=$5 WHERE topic=$6 AND userid=$7", sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, sub.Topic, decoded_uid) @@ -1490,12 +1490,12 @@ func createSubscription(ctx context.Context, tx pgx.Tx, sub *t.Subscription, und } } if err == nil && isOwner { - _, err = tx.Exec(ctx, "UPDATE topics SET owner=$1 WHERE name=$2", decoded_uid, sub.Topic) + _, err = tx.Exec(ctx, "UPDATE im_topics SET owner=$1 WHERE name=$2", decoded_uid, sub.Topic) } return err } -// TopicCreateP2P given two users creates a p2p topic +// TopicCreateP2P given two im_users creates a p2p topic func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error { ctx, cancel := a.getContextForTx() if cancel != nil { @@ -1544,7 +1544,7 @@ func (a *adapter) TopicGet(topic string) (*t.Topic, error) { var owner int64 err := a.db.QueryRow(ctx, "SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+ - "FROM topics WHERE name=$1", + "FROM im_topics WHERE name=$1", topic).Scan(&tt.CreatedAt, &tt.UpdatedAt, &tt.State, &tt.StateAt, &tt.TouchedAt, &tt.Id, &tt.UseBt, &tt.Access, &owner, &tt.SeqId, &tt.DelId, &tt.SubCnt, &tt.Public, &tt.Trusted, &tt.Tags, &tt.Aux) if err != nil { @@ -1559,7 +1559,7 @@ func (a *adapter) TopicGet(topic string) (*t.Topic, error) { // Topic found, get subsription count. Try both topic and channel names. var subCnt int if err = a.db.QueryRow(ctx, - "SELECT COUNT(*) FROM subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL", topic, t.GrpToChn(topic)). + "SELECT COUNT(*) FROM im_subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL", topic, t.GrpToChn(topic)). Scan(&subCnt); err != nil { return nil, err } @@ -1567,7 +1567,7 @@ func (a *adapter) TopicGet(topic string) (*t.Topic, error) { if subCnt != tt.SubCnt { // Update the topic with the correct subscription count. tt.SubCnt = subCnt - if _, err = a.db.Exec(ctx, "UPDATE topics SET subcnt=$1 WHERE name=$2", subCnt, topic); err != nil { + if _, err = a.db.Exec(ctx, "UPDATE im_topics SET subcnt=$1 WHERE name=$2", subCnt, topic); err != nil { return nil, err } } @@ -1578,13 +1578,13 @@ func (a *adapter) TopicGet(topic string) (*t.Topic, error) { return tt, err } -// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions. +// TopicsForUser loads user's contact list: p2p and grp im_topics, except for 'me' & 'fnd' im_subscriptions. // Reads and denormalizes Public value. func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { - // Fetch ALL user's subscriptions, even those which has not been modified recently. - // We are going to use these subscriptions to fetch topics and users which may have been modified recently. + // Fetch ALL user's im_subscriptions, even those which has not been modified recently. + // We are going to use these im_subscriptions to fetch im_topics and im_users which may have been modified recently. q := `SELECT createdat,updatedat,deletedat,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE userid=?` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE userid=?` args := []any{store.DecodeUid(uid)} if !keepDeleted { // Filter out deleted rows. @@ -1600,7 +1600,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( } // Apply the limit only when the client does not manage the cache (or cold start). - // Otherwise have to get all subscriptions and do a manual join with users/topics. + // Otherwise have to get all im_subscriptions and do a manual join with im_users/im_topics. if opts.IfModifiedSince == nil { if opts.Limit > 0 && opts.Limit < a.maxResults { limit = opts.Limit @@ -1631,8 +1631,8 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( } // Must close rows manually as we will be reusing it. - // Fetch subscriptions. Two queries are needed: users table (p2p) and topics table (grp). - // Prepare a list of separate subscriptions to users vs topics + // Fetch im_subscriptions. Two queries are needed: im_users table (p2p) and im_topics table (grp). + // Prepare a list of separate im_subscriptions to im_users vs im_topics join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access topq := make([]any, 0, 16) usrq := make([]any, 0, 16) @@ -1650,7 +1650,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( tcat := t.GetTopicCat(tname) if tcat == t.TopicCatMe || tcat == t.TopicCatFnd { - // One of 'me', 'fnd' subscriptions, skip. + // One of 'me', 'fnd' im_subscriptions, skip. // Don't skip 'sys' subscription. continue } else if tcat == t.TopicCatP2P { @@ -1667,7 +1667,7 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( // Maybe convert channel name to topic name. tname = t.ChnToGrp(tname) } - // No special handling needed for 'slf', 'sys' subscriptions. + // No special handling needed for 'slf', 'sys' im_subscriptions. topq = append(topq, tname) sub.Private = common.FromJSON(sub.Private) @@ -1687,14 +1687,14 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( return subs, nil } - // Fetch grp topics and join to subscriptions. + // Fetch grp im_topics and join to im_subscriptions. if len(topq) > 0 { q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " + - "FROM topics WHERE name IN (?)" + "FROM im_topics WHERE name IN (?)" newargs := []any{topq} if !keepDeleted { - // Optionally skip deleted topics. + // Optionally skip deleted im_topics. q += " AND state!=?" newargs = append(newargs, t.StateDeleted) } @@ -1752,18 +1752,18 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( } } - // Fetch p2p users and join to p2p subscriptions. + // Fetch p2p im_users and join to p2p im_subscriptions. if len(usrq) > 0 { q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " + - "FROM users WHERE id IN (?)" + "FROM im_users WHERE id IN (?)" newargs := []any{usrq} if !keepDeleted { - // Optionally skip deleted users. + // Optionally skip deleted im_users. q += " AND state!=?" newargs = append(newargs, t.StateDeleted) } - // Ignoring ipg: we need all users to get LastSeen and UserAgent. + // Ignoring ipg: we need all im_users to get LastSeen and UserAgent. q, newargs = expandQuery(q, newargs...) @@ -1814,27 +1814,27 @@ func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ( return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil } -// UsersForTopic loads users subscribed to the given topic. +// UsersForTopic loads im_users subscribed to the given topic. // The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public, // the latter does not. func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { tcat := t.GetTopicCat(topic) - // Fetch all subscribed users. The number of users is not large + // Fetch all subscribed im_users. The number of im_users is not large q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private - FROM subscriptions AS s JOIN users AS u ON s.userid=u.id + FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id WHERE s.topic=?` args := []any{topic} if !keepDeleted { - // Filter out rows with users deleted + // Filter out rows with im_users deleted q += " AND u.state!=?" args = append(args, t.StateDeleted) - // For p2p topics we must load all subscriptions including deleted. + // For p2p im_topics we must load all im_subscriptions including deleted. // Otherwise it will be impossible to swipe Public values. if tcat != t.TopicCatP2P { - // Filter out deleted subscriptions. + // Filter out deleted im_subscriptions. q += " AND s.deletedat IS NULL" } } @@ -1846,7 +1846,7 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt // Those unmodified will be stripped of Public & Private. if !opts.User.IsZero() { - // For p2p topics we have to fetch both users otherwise public cannot be swapped. + // For p2p im_topics we have to fetch both im_users otherwise public cannot be swapped. if tcat != t.TopicCatP2P { q += " AND s.userid=?" args = append(args, store.DecodeUid(opts.User)) @@ -1871,7 +1871,7 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt } defer rows.Close() - // Fetch subscriptions + // Fetch im_subscriptions var sub t.Subscription var subs []t.Subscription var userId int64 @@ -1901,7 +1901,7 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt } if err == nil && tcat == t.TopicCatP2P && len(subs) > 0 { - // Swap public & lastSeen values of P2P topics as expected. + // Swap public & lastSeen values of P2P im_topics as expected. if len(subs) == 1 { // The other user is deleted, nothing we can do. subs[0].SetPublic(nil) @@ -1922,7 +1922,7 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt subs[1].SetLastSeenAndUA(lastSeen, userAgent) } - // Remove deleted and unneeded subscriptions + // Remove deleted and unneeded im_subscriptions if !keepDeleted || !oneUser.IsZero() { var xsubs []t.Subscription for i := range subs { @@ -1973,18 +1973,18 @@ func (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...a // OwnTopics loads a slice of topic names where the user is the owner. func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) { - return a.topicNamesForUser("SELECT name FROM topics WHERE owner=$1 AND state!=$2", + return a.topicNamesForUser("SELECT name FROM im_topics WHERE owner=$1 AND state!=$2", false, store.DecodeUid(uid), t.StateDeleted) } // ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled. func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) { - return a.topicNamesForUser("SELECT topic FROM subscriptions WHERE userid=$1 AND topic LIKE 'chn%' "+ + return a.topicNamesForUser("SELECT topic FROM im_subscriptions WHERE userid=$1 AND topic LIKE 'chn%' "+ "AND POSITION('P' IN modewant)>0 AND POSITION('P' IN modegiven)>0 AND deletedat IS NULL", false, store.DecodeUid(uid)) } -// TopicShare creates topic subscriptions and increments the topic's subcnt. +// TopicShare creates topic im_subscriptions and increments the topic's subcnt. func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error { ctx, cancel := a.getContextForTx() if cancel != nil { @@ -2008,7 +2008,7 @@ func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error { } if topic != "" { - if _, err = tx.Exec(ctx, "UPDATE topics SET subcnt=subcnt+$1 WHERE name=$2", len(shares), topic); err != nil { + if _, err = tx.Exec(ctx, "UPDATE im_topics SET subcnt=subcnt+$1 WHERE name=$2", len(shares), topic); err != nil { return err } } @@ -2016,7 +2016,7 @@ func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error { return tx.Commit(ctx) } -// TopicDelete deletes topic, subscriptions, messages. +// TopicDelete deletes topic, im_subscriptions, im_messages. func (a *adapter) TopicDelete(topic string, isChan, hard bool) error { ctx, cancel := a.getContextForTx() if cancel != nil { @@ -2033,15 +2033,15 @@ func (a *adapter) TopicDelete(topic string, isChan, hard bool) error { } }() - // If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names. + // If the topic is a channel, must try to delete im_subscriptions under both grpXXX and chnXXX names. args := []any{topic} if isChan { args = append(args, t.GrpToChn(topic)) } if hard { - // Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions. - q, args := expandQuery("DELETE FROM subscriptions WHERE topic IN (?)", args) + // Delete im_subscriptions. If this is a channel, delete both group im_subscriptions and channel im_subscriptions. + q, args := expandQuery("DELETE FROM im_subscriptions WHERE topic IN (?)", args) if _, err = tx.Exec(ctx, q, args...); err != nil { return err } @@ -2050,22 +2050,22 @@ func (a *adapter) TopicDelete(topic string, isChan, hard bool) error { return err } - if _, err = tx.Exec(ctx, "DELETE FROM topictags WHERE topic=$1", topic); err != nil { + if _, err = tx.Exec(ctx, "DELETE FROM im_topictags WHERE topic=$1", topic); err != nil { return err } - if _, err = tx.Exec(ctx, "DELETE FROM topics WHERE name=$1", topic); err != nil { + if _, err = tx.Exec(ctx, "DELETE FROM im_topics WHERE name=$1", topic); err != nil { return err } } else { now := t.TimeNow() - q, args := expandQuery("UPDATE subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args) + q, args := expandQuery("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args) if _, err = tx.Exec(ctx, q, args...); err != nil { return err } - if _, err = tx.Exec(ctx, "UPDATE topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE name=$3", + if _, err = tx.Exec(ctx, "UPDATE im_topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE name=$3", now, t.StateDeleted, topic); err != nil { return err } @@ -2078,7 +2078,7 @@ func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error { if cancel != nil { defer cancel() } - _, err := a.db.Exec(ctx, "UPDATE topics SET seqid=$1,touchedat=$2 WHERE name=$3", msg.SeqId, msg.CreatedAt, topic) + _, err := a.db.Exec(ctx, "UPDATE im_topics SET seqid=$1,touchedat=$2 WHERE name=$3", msg.SeqId, msg.CreatedAt, topic) return err } @@ -2090,7 +2090,7 @@ func (a *adapter) TopicUpdateSubCnt(topic string) error { defer cancel() } _, err := a.db.Exec(ctx, - "UPDATE topics SET subcnt=(SELECT COUNT(*) FROM subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL) WHERE name=$1", + "UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL) WHERE name=$1", topic, t.GrpToChn(topic)) return err } @@ -2115,7 +2115,7 @@ func (a *adapter) TopicUpdate(topic string, update map[string]any) error { update["TouchedAt"] = u } cols, args := common.UpdateByMap(update) - q, args := expandQuery("UPDATE topics SET "+strings.Join(cols, ",")+" WHERE name=?", args, topic) + q, args := expandQuery("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE name=?", args, topic) _, err = tx.Exec(ctx, q, args...) if err != nil { return err @@ -2124,12 +2124,12 @@ func (a *adapter) TopicUpdate(topic string, 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(ctx, "DELETE FROM topictags WHERE topic=$1", topic) + _, err = tx.Exec(ctx, "DELETE FROM im_topictags WHERE topic=$1", topic) if err != nil { return err } // Now insert new tags - err = addTags(ctx, tx, "topictags", "topic", topic, tags, false) + err = addTags(ctx, tx, "im_topictags", "topic", topic, tags, false) if err != nil { return err } @@ -2143,7 +2143,7 @@ func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error { if cancel != nil { defer cancel() } - _, err := a.db.Exec(ctx, "UPDATE topics SET owner=$1 WHERE name=$2", store.DecodeUid(newOwner), topic) + _, err := a.db.Exec(ctx, "UPDATE im_topics SET owner=$1 WHERE name=$2", store.DecodeUid(newOwner), topic) return err } @@ -2154,7 +2154,7 @@ func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (* defer cancel() } query := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=$1 AND userid=$2` if !keepDeleted { query += " AND deletedat IS NULL" } @@ -2179,11 +2179,11 @@ func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (* return &sub, nil } -// SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does -// not load deleted subscriptions. +// SubsForUser loads all user's im_subscriptions. Does NOT load Public or Private values and does +// not load deleted im_subscriptions. func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) { q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven FROM subscriptions WHERE userid=$1 AND deletedat IS NULL` + readseqid,modewant,modegiven FROM im_subscriptions WHERE userid=$1 AND deletedat IS NULL` args := []any{store.DecodeUid(forUser)} ctx, cancel := a.getContext() @@ -2223,7 +2223,7 @@ func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) { // the latter does not. func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, - readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=?` + readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=?` args := []any{topic} if !keepDeleted { @@ -2280,7 +2280,7 @@ func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) return subs, err } -// SubsUpdate updates one or multiple subscriptions to a topic. +// SubsUpdate updates one or multiple im_subscriptions to a topic. func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error { ctx, cancel := a.getContextForTx() if cancel != nil { @@ -2298,7 +2298,7 @@ func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) er }() cols, args := common.UpdateByMap(update) - q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?" + q := "UPDATE im_subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?" args = append(args, topic) if !user.IsZero() { // Update just one topic subscription @@ -2335,7 +2335,7 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { decoded_id := store.DecodeUid(user) now := t.TimeNow() res, err := tx.Exec(ctx, - "UPDATE subscriptions SET updatedat=$1,deletedat=$2 WHERE topic=$3 AND userid=$4 AND deletedat IS NULL", + "UPDATE im_subscriptions SET updatedat=$1,deletedat=$2 WHERE topic=$3 AND userid=$4 AND deletedat IS NULL", now, now, topic, decoded_id) if err != nil { return err @@ -2348,10 +2348,10 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { return err } - // Channel readers cannot delete messages. + // Channel readers cannot delete im_messages. if !t.IsChannel(topic) { - // Remove records of messages soft-deleted by this user. - _, err = tx.Exec(ctx, "DELETE FROM dellog WHERE topic=$1 AND deletedfor=$2", topic, decoded_id) + // Remove records of im_messages soft-deleted by this user. + _, err = tx.Exec(ctx, "DELETE FROM im_dellog WHERE topic=$1 AND deletedfor=$2", topic, decoded_id) if err != nil { return err } @@ -2359,7 +2359,7 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { if t.GetTopicCat(topic) == t.TopicCatGrp { // Decrement topic subscription count (only one subscription is deleted). - _, err = tx.Exec(ctx, "UPDATE topics SET subcnt=subcnt-1 WHERE name=$1", topic) + _, err = tx.Exec(ctx, "UPDATE im_topics SET subcnt=subcnt-1 WHERE name=$1", topic) if err != nil { return err } @@ -2368,14 +2368,14 @@ func (a *adapter) SubsDelete(topic string, user t.Uid) error { return tx.Commit(ctx) } -// subsDelForUser marks user's subscriptions as deleted. +// subsDelForUser marks user's im_subscriptions as deleted. func subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool) error { - // Decrement subscription count for all topics the user is subscribed to. - rows, err := tx.Query(ctx, "SELECT topic FROM subscriptions WHERE userid=$1 AND deletedat IS NULL", decoded_uid) + // Decrement subscription count for all im_topics the user is subscribed to. + rows, err := tx.Query(ctx, "SELECT topic FROM im_subscriptions WHERE userid=$1 AND deletedat IS NULL", decoded_uid) if err != nil { return err } - var topics []any + var im_topics []any for rows.Next() { var name string if err = rows.Scan(&name); err != nil { @@ -2385,7 +2385,7 @@ func subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool // Convert channel name to group name. name = t.ChnToGrp(name) } - topics = append(topics, name) + im_topics = append(im_topics, name) } if err == nil { err = rows.Err() @@ -2394,8 +2394,8 @@ func subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool if err != nil { return err } - if len(topics) > 0 { - sql, args, _ := sqlx.In("UPDATE topics SET subcnt=subcnt-1 WHERE name IN (?)", topics) + if len(im_topics) > 0 { + sql, args, _ := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE name IN (?)", im_topics) _, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...) if err != nil { return err @@ -2403,17 +2403,17 @@ func subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool } if hard { - // Hard delete: remove all subscriptions for the user. - _, err = tx.Exec(ctx, "DELETE FROM subscriptions WHERE userid=$1", decoded_uid) + // Hard delete: remove all im_subscriptions for the user. + _, err = tx.Exec(ctx, "DELETE FROM im_subscriptions WHERE userid=$1", decoded_uid) } else { now := t.TimeNow() - _, err = tx.Exec(ctx, "UPDATE subscriptions SET updatedat=$1,deletedat=$2 WHERE userid=$3 AND deletedat IS NULL;", + _, err = tx.Exec(ctx, "UPDATE im_subscriptions SET updatedat=$1,deletedat=$2 WHERE userid=$3 AND deletedat IS NULL;", now, now, decoded_uid) } return err } -// SubsDelForUser marks user's subscriptions as deleted. +// SubsDelForUser marks user's im_subscriptions as deleted. func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error { ctx, cancel := a.getContextForTx() if cancel != nil { @@ -2439,7 +2439,7 @@ func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error { } -// Find returns a list of users and group topics which match the given tags, such as "email:jdoe@example.com" or "tel:+18003287448". +// Find returns a list of im_users and group im_topics which match the given tags, such as "email:jdoe@example.com" or "tel:+18003287448". func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) { index := make(map[string]struct{}) var args []any @@ -2474,7 +2474,7 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, query := "SELECT CAST(u.id AS VARCHAR) AS topic,u.createdat,u.updatedat,FALSE,u.access::jsonb,0 AS subcnt,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb," + matcher + " AS matches " + - "FROM users AS u JOIN usertags AS tg ON tg.userid=u.id " + + "FROM im_users AS u JOIN im_usertags AS tg ON tg.userid=u.id " + "WHERE " + constraint + "GROUP BY u.id,u.createdat,u.updatedat,u.access::jsonb,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb " @@ -2491,7 +2491,7 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb," + matcher + " AS matches " + - "FROM topics AS t JOIN topictags AS tg ON t.name=tg.topic " + + "FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " + "WHERE " + constraint + "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb " if having != "" { @@ -2505,14 +2505,14 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, defer cancel() } - // Get users matched by tags, sort by number of matches from high to low. + // Get im_users matched by tags, sort by number of matches from high to low. rows, err := a.db.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() - // Fetch subscriptions + // Fetch im_subscriptions var public, trusted any var access t.DefaultAccess var subcnt int @@ -2563,13 +2563,13 @@ func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, // FindOne returns topic or user which matches the given tag. func (a *adapter) FindOne(tag string) (string, error) { var args []any - query := "SELECT t.name AS topic FROM topics AS t LEFT JOIN topictags AS tt ON t.name=tt.topic " + + 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 CAST(u.id AS VARCHAR) AS topic FROM users AS u LEFT JOIN usertags AS ut ON ut.userid=u.id " + + query += "SELECT CAST(u.id AS VARCHAR) 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) @@ -2617,7 +2617,7 @@ func (a *adapter) MessageSave(msg *t.Message) error { // Using a sequential ID provided by the database. var id int err := a.db.QueryRow(ctx, - `INSERT INTO messages(createdAt,updatedAt,seqid,topic,"from",head,content) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + `INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,"from",head,content) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, store.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content)).Scan(&id) if err == nil { @@ -2666,7 +2666,7 @@ func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ( } query, args := expandQuery(`SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m."from",m.head,m.content`+ - " FROM messages AS m LEFT JOIN dellog AS d"+ + " FROM im_messages AS m LEFT JOIN im_dellog AS d"+ " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+ " WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+ " ORDER BY m.seqid DESC LIMIT ?", args...) @@ -2694,7 +2694,7 @@ func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ( return msgs, err } -// Get ranges of deleted messages +// Get ranges of deleted im_messages func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { var limit = a.maxResults var lower = 0 @@ -2719,7 +2719,7 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp if cancel != nil { defer cancel() } - rows, err := a.db.Query(ctx, "SELECT topic,deletedfor,delid,low,hi FROM dellog WHERE topic=$1 AND delid BETWEEN $2 AND $3"+ + rows, err := a.db.Query(ctx, "SELECT topic,deletedfor,delid,low,hi FROM im_dellog WHERE topic=$1 AND delid BETWEEN $2 AND $3"+ " AND (deletedFor=0 OR deletedFor=$4) ORDER BY delid LIMIT $5", topic, lower, upper, store.DecodeUid(forUser), limit) if err != nil { @@ -2727,7 +2727,7 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp } defer rows.Close() - var dellog struct { + var im_dellog struct { Topic string Deletedfor int64 Delid int @@ -2737,28 +2737,28 @@ func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOp var dmsgs []t.DelMessage var dmsg t.DelMessage for rows.Next() { - if err = rows.Scan(&dellog.Topic, &dellog.Deletedfor, &dellog.Delid, &dellog.Low, &dellog.Hi); err != nil { + if err = rows.Scan(&im_dellog.Topic, &im_dellog.Deletedfor, &im_dellog.Delid, &im_dellog.Low, &im_dellog.Hi); err != nil { dmsgs = nil break } - if dellog.Delid != dmsg.DelId { + if im_dellog.Delid != dmsg.DelId { if dmsg.DelId > 0 { dmsgs = append(dmsgs, dmsg) } - dmsg.DelId = dellog.Delid - dmsg.Topic = dellog.Topic - if dellog.Deletedfor > 0 { - dmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String() + dmsg.DelId = im_dellog.Delid + dmsg.Topic = im_dellog.Topic + if im_dellog.Deletedfor > 0 { + dmsg.DeletedFor = store.EncodeUid(im_dellog.Deletedfor).String() } else { dmsg.DeletedFor = "" } dmsg.SeqIdRanges = nil } - if dellog.Hi <= dellog.Low+1 { - dellog.Hi = 0 + if im_dellog.Hi <= im_dellog.Low+1 { + im_dellog.Hi = 0 } - dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: dellog.Low, Hi: dellog.Hi}) + dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: im_dellog.Low, Hi: im_dellog.Hi}) } if err == nil { err = rows.Err() @@ -2777,21 +2777,21 @@ func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.De var err error if toDel == nil { - // Whole topic is being deleted, thus also deleting all messages. - _, err = tx.Exec(ctx, "DELETE FROM dellog WHERE topic=$1", topic) + // Whole topic is being deleted, thus also deleting all im_messages. + _, err = tx.Exec(ctx, "DELETE FROM im_dellog WHERE topic=$1", topic) if err == nil { - _, err = tx.Exec(ctx, "DELETE FROM messages WHERE topic=$1", topic) + _, err = tx.Exec(ctx, "DELETE FROM im_messages WHERE topic=$1", topic) } - // filemsglinks will be deleted because of ON DELETE CASCADE + // im_filemsglinks will be deleted because of ON DELETE CASCADE return err } - // Only some messages are being deleted + // Only some im_messages are being deleted delRanges := toDel.SeqIdRanges if toDel.DeletedFor == "" { - // Hard-deleting messages requires updates to the messages table. + // Hard-deleting im_messages requires updates to the im_messages table. where := "m.topic=? " args := []any{topic} @@ -2803,7 +2803,7 @@ func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.De where += " AND m.deletedat IS NULL" - // We are asked to delete messages no older than newerThan. + // We are asked to delete im_messages no older than newerThan. if newerThan := toDel.GetNewerThan(); newerThan != nil { where += " AND m.createdat>?" args = append(args, newerThan) @@ -2811,7 +2811,7 @@ func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.De // Find the actual IDs still present in the database. var seqIDs []int - query, newargs := expandQuery("SELECT seqid FROM messages AS m WHERE "+where, args) + query, newargs := expandQuery("SELECT seqid FROM im_messages AS m WHERE "+where, args) rows, err := tx.Query(ctx, query, newargs...) if err != nil { return err @@ -2847,14 +2847,14 @@ func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.De // No need to add anything else: deletedat etc is already accounted for. - query, newargs = expandQuery("DELETE FROM filemsglinks AS fml USING messages AS m WHERE m.id=fml.msgid AND "+ + query, newargs = expandQuery("DELETE FROM im_filemsglinks AS fml USING im_messages AS m WHERE m.id=fml.msgid AND "+ where, args...) _, err = tx.Exec(ctx, query, newargs...) if err != nil { return err } - query, newargs = expandQuery(`UPDATE messages AS m SET deletedat=?,delid=?,"from"=0,head=NULL,content=NULL WHERE `+ + query, newargs = expandQuery(`UPDATE im_messages AS m SET deletedat=?,delid=?,"from"=0,head=NULL,content=NULL WHERE `+ where, t.TimeNow(), toDel.DelId, args) _, err = tx.Exec(ctx, query, newargs...) if err != nil { @@ -2872,7 +2872,7 @@ func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.De rng.Hi = rng.Low + 1 } - if _, err = tx.Exec(ctx, "INSERT INTO dellog(topic,deletedfor,delid,low,hi) VALUES($1,$2,$3,$4,$5)", + if _, err = tx.Exec(ctx, "INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES($1,$2,$3,$4,$5)", topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil { break } @@ -2881,7 +2881,7 @@ func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.De return err } -// MessageDeleteList deletes messages in the given topic with seqIds from the list. +// MessageDeleteList deletes im_messages in the given topic with seqIds from the list. func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) (err error) { ctx, cancel := a.getContextForTx() if cancel != nil { @@ -2932,13 +2932,13 @@ func (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error { }() // Ensure uniqueness of the device ID: delete all records of the device ID - _, err = tx.Exec(ctx, "DELETE FROM devices WHERE hash=$1", hash) + _, err = tx.Exec(ctx, "DELETE FROM im_devices WHERE hash=$1", hash) if err != nil { return err } // Actually add/update DeviceId for the new user - _, err = tx.Exec(ctx, "INSERT INTO devices(userid, hash, deviceId, platform, lastseen, lang) VALUES($1,$2,$3,$4,$5,$6)", + _, err = tx.Exec(ctx, "INSERT INTO im_devices(userid, hash, deviceId, platform, lastseen, lang) VALUES($1,$2,$3,$4,$5,$6)", store.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang) if err != nil { return err @@ -2953,7 +2953,7 @@ func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, err unums = append(unums, store.DecodeUid(uid)) } - query, unums := expandQuery("SELECT userid,deviceid,platform,lastseen,lang FROM devices WHERE userid IN (?)", unums) + query, unums := expandQuery("SELECT userid,deviceid,platform,lastseen,lang FROM im_devices WHERE userid IN (?)", unums) ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -3000,9 +3000,9 @@ func deviceDelete(ctx context.Context, tx pgx.Tx, uid t.Uid, deviceID string) er var err error var res pgconn.CommandTag if deviceID == "" { - res, err = tx.Exec(ctx, "DELETE FROM devices WHERE userid=$1", store.DecodeUid(uid)) + res, err = tx.Exec(ctx, "DELETE FROM im_devices WHERE userid=$1", store.DecodeUid(uid)) } else { - res, err = tx.Exec(ctx, "DELETE FROM devices WHERE userid=$1 AND hash=$2", store.DecodeUid(uid), deviceHasher(deviceID)) + res, err = tx.Exec(ctx, "DELETE FROM im_devices WHERE userid=$1 AND hash=$2", store.DecodeUid(uid), deviceHasher(deviceID)) } if err == nil { @@ -3075,7 +3075,7 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { if !cred.Done { // Check if this credential is already validated. var done bool - err = tx.QueryRow(ctx, "SELECT done FROM credentials WHERE synthetic=$1", synth).Scan(&done) + err = tx.QueryRow(ctx, "SELECT done FROM im_credentials WHERE synthetic=$1", synth).Scan(&done) if err == nil { // Assign err to ensure closing of a transaction. err = t.ErrDuplicate @@ -3088,13 +3088,13 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { synth = cred.User + ":" + synth // Adding new unvalidated credential. Deactivate all unvalidated records of this user and method. - _, err = tx.Exec(ctx, "UPDATE credentials SET deletedat=$1 WHERE userid=$2 AND method=$3 AND done=FALSE", + _, err = tx.Exec(ctx, "UPDATE im_credentials SET deletedat=$1 WHERE userid=$2 AND method=$3 AND done=FALSE", now, userId, cred.Method) if err != nil { return false, err } // Assume that the record exists and try to update it: undelete, update timestamp and response value. - res, err := tx.Exec(ctx, "UPDATE credentials SET updatedat=$1,deletedat=NULL,resp=$2,done=FALSE WHERE synthetic=$3", + res, err := tx.Exec(ctx, "UPDATE im_credentials SET updatedat=$1,deletedat=NULL,resp=$2,done=FALSE WHERE synthetic=$3", cred.UpdatedAt, cred.Resp, synth) if err != nil { return false, err @@ -3105,13 +3105,13 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { } } else { // Hard-deleting unconformed record if it exists. - _, err = tx.Exec(ctx, "DELETE FROM credentials WHERE synthetic=$1", cred.User+":"+synth) + _, err = tx.Exec(ctx, "DELETE FROM im_credentials WHERE synthetic=$1", cred.User+":"+synth) if err != nil { return false, err } } - _, err = tx.Exec(ctx, "INSERT INTO credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ + _, err = tx.Exec(ctx, "INSERT INTO im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ "VALUES($1,$2,$3,$4,$5,$6,$7,$8)", cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done) if err != nil { @@ -3148,7 +3148,7 @@ func credDel(ctx context.Context, tx pgx.Tx, uid t.Uid, method, value string) er var res pgconn.CommandTag if method == "" { // Case 1 - res, err = tx.Exec(ctx, "DELETE FROM credentials"+where, args...) + res, err = tx.Exec(ctx, "DELETE FROM im_credentials"+where, args...) if err == nil { if count := res.RowsAffected(); count == 0 { err = t.ErrNotFound @@ -3158,7 +3158,7 @@ func credDel(ctx context.Context, tx pgx.Tx, uid t.Uid, method, value string) er } // Case 2.1 - res, err = tx.Exec(ctx, "DELETE FROM credentials"+where+" AND (done=TRUE OR retries=0)", args...) + res, err = tx.Exec(ctx, "DELETE FROM im_credentials"+where+" AND (done=TRUE OR retries=0)", args...) if err != nil { return err } @@ -3167,7 +3167,7 @@ func credDel(ctx context.Context, tx pgx.Tx, uid t.Uid, method, value string) er } // Case 2.2 - query, args := expandQuery("UPDATE credentials SET deletedat=?"+constraints, t.TimeNow(), args) + query, args := expandQuery("UPDATE im_credentials SET deletedat=?"+constraints, t.TimeNow(), args) res, err = tx.Exec(ctx, query, args...) if err == nil { if count := res.RowsAffected(); count >= 0 { @@ -3178,8 +3178,8 @@ func credDel(ctx context.Context, tx pgx.Tx, uid t.Uid, method, value string) er return err } -// CredDel deletes either credentials of the given user. If method is blank all -// credentials are removed. If value is blank all credentials of the given the +// CredDel deletes either im_credentials of the given user. If method is blank all +// im_credentials are removed. If value is blank all im_credentials of the given the // method are removed. func (a *adapter) CredDel(uid t.Uid, method, value string) error { ctx, cancel := a.getContextForTx() @@ -3212,7 +3212,7 @@ func (a *adapter) CredConfirm(uid t.Uid, method string) error { } res, err := a.db.Exec( ctx, - "UPDATE credentials SET updatedat=$1,done=TRUE,synthetic=CONCAT(method,':',value) "+ + "UPDATE im_credentials SET updatedat=$1,done=TRUE,synthetic=CONCAT(method,':',value) "+ "WHERE userid=$2 AND method=$3 AND deletedat IS NULL AND done=FALSE", t.TimeNow(), store.DecodeUid(uid), method) if err != nil { @@ -3233,7 +3233,7 @@ func (a *adapter) CredFail(uid t.Uid, method string) error { if cancel != nil { defer cancel() } - _, err := a.db.Exec(ctx, "UPDATE credentials SET updatedat=$1,retries=retries+1 WHERE userid=$2 AND method=$3 AND done=FALSE", + _, err := a.db.Exec(ctx, "UPDATE im_credentials SET updatedat=$1,retries=retries+1 WHERE userid=$2 AND method=$3 AND done=FALSE", t.TimeNow(), store.DecodeUid(uid), method) return err } @@ -3247,7 +3247,7 @@ func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) var cred t.Credential err := a.db.QueryRow(ctx, "SELECT createdat,updatedat,method,value,resp,done,retries "+ - "FROM credentials WHERE userid=$1 AND deletedat IS NULL AND method=$2 AND done=FALSE", + "FROM im_credentials WHERE userid=$1 AND deletedat IS NULL AND method=$2 AND done=FALSE", store.DecodeUid(uid), method).Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries) if err != nil { if err == pgx.ErrNoRows { @@ -3262,7 +3262,7 @@ func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) // CredGetAll returns credential records for the given user and method, all or validated only. func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) { - query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials WHERE userid=$1 AND deletedat IS NULL" + query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM im_credentials WHERE userid=$1 AND deletedat IS NULL" args := []any{store.DecodeUid(uid)} if method != "" { query += " AND method=$2" @@ -3276,7 +3276,7 @@ func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t. if cancel != nil { defer cancel() } - var credentials []t.Credential + var im_credentials []t.Credential rows, err := a.db.Query(ctx, query, args...) if err != nil { return nil, err @@ -3286,19 +3286,19 @@ func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t. for rows.Next() { var cred t.Credential if err = rows.Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries); err != nil { - credentials = nil + im_credentials = nil break } - credentials = append(credentials, cred) + im_credentials = append(im_credentials, cred) } user := uid.String() - for i := range credentials { - credentials[i].User = user + for i := range im_credentials { + im_credentials[i].User = user } - return credentials, err + return im_credentials, err } // FileUploads @@ -3314,7 +3314,7 @@ func (a *adapter) FileStartUpload(fd *t.FileDef) error { user = store.DecodeUid(t.ParseUid(fd.User)) } _, err := a.db.Exec(ctx, - "INSERT INTO fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+ + "INSERT INTO im_fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+ "VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) @@ -3339,7 +3339,7 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t. now := t.TimeNow() if success { - _, err = tx.Exec(ctx, "UPDATE fileuploads SET updatedat=$1,status=$2,size=$3,etag=$4,location=$5 WHERE id=$6", + _, err = tx.Exec(ctx, "UPDATE im_fileuploads SET updatedat=$1,status=$2,size=$3,etag=$4,location=$5 WHERE id=$6", now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid())) if err != nil { return nil, err @@ -3349,7 +3349,7 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t. fd.Size = size } else { // Deleting the record: there is no value in keeping it in the DB. - _, err = tx.Exec(ctx, "DELETE FROM fileuploads WHERE id=$1", store.DecodeUid(fd.Uid())) + _, err = tx.Exec(ctx, "DELETE FROM im_fileuploads WHERE id=$1", store.DecodeUid(fd.Uid())) if err != nil { return nil, err } @@ -3377,7 +3377,7 @@ func (a *adapter) FileGet(fid string) (*t.FileDef, error) { var ID int64 var userId int64 err := a.db.QueryRow(ctx, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,etag,location "+ - "FROM fileuploads WHERE id=$1", store.DecodeUid(id)).Scan(&ID, &fd.CreatedAt, &fd.UpdatedAt, &userId, &fd.Status, + "FROM im_fileuploads WHERE id=$1", store.DecodeUid(id)).Scan(&ID, &fd.CreatedAt, &fd.UpdatedAt, &userId, &fd.Status, &fd.MimeType, &fd.Size, &fd.ETag, &fd.Location) if err == pgx.ErrNoRows { return nil, nil @@ -3409,7 +3409,7 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er }() // Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned. - query := "SELECT fu.id,fu.location FROM fileuploads AS fu LEFT JOIN filemsglinks AS fml ON fml.fileid=fu.id " + + query := "SELECT fu.id,fu.location FROM im_fileuploads AS fu LEFT JOIN im_filemsglinks AS fml ON fml.fileid=fu.id " + "WHERE fml.id IS NULL" var args []any if !olderThan.IsZero() { @@ -3450,7 +3450,7 @@ func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, er } if len(ids) > 0 { - query, ids = expandQuery("DELETE FROM fileuploads WHERE id IN (?)", ids) + query, ids = expandQuery("DELETE FROM im_fileuploads WHERE id IN (?)", ids) _, err = tx.Exec(ctx, query, ids...) if err != nil { return nil, err @@ -3516,14 +3516,14 @@ func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids [] // Unlink earlier uploads on the same topic or user allowing them to be garbage-collected. if msgId.IsZero() { - sql := "DELETE FROM filemsglinks WHERE " + linkBy + "=$1" + sql := "DELETE FROM im_filemsglinks WHERE " + linkBy + "=$1" _, err = tx.Exec(ctx, sql, linkId) if err != nil { return err } } - query, args := expandQuery("INSERT INTO filemsglinks(createdat,fileid,"+linkBy+") VALUES (?,?,?)"+ + query, args := expandQuery("INSERT INTO im_filemsglinks(createdat,fileid,"+linkBy+") VALUES (?,?,?)"+ strings.Repeat(",(?,?,?)", len(dids)-1), args...) _, err = tx.Exec(ctx, query, args...) if err != nil { @@ -3541,7 +3541,7 @@ func (a *adapter) PCacheGet(key string) (string, error) { } var value string - if err := a.db.QueryRow(ctx, `SELECT "value" FROM kvmeta WHERE "key"=$1 LIMIT 1`, key).Scan(&value); err != nil { + if err := a.db.QueryRow(ctx, `SELECT "value" FROM im_kvmeta WHERE "key"=$1 LIMIT 1`, key).Scan(&value); err != nil { if err == pgx.ErrNoRows { return "", t.ErrNotFound } @@ -3567,7 +3567,7 @@ func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) e action = ` ON CONFLICT ("key") DO UPDATE SET createdat=$2,"value"=$3` } - _, err := a.db.Exec(ctx, `INSERT INTO kvmeta("key",createdat,"value") VALUES($1,$2,$3)`+action, + _, err := a.db.Exec(ctx, `INSERT INTO im_kvmeta("key",createdat,"value") VALUES($1,$2,$3)`+action, key, t.TimeNow(), value) if isDupe(err) { return t.ErrDuplicate @@ -3582,7 +3582,7 @@ func (a *adapter) PCacheDelete(key string) error { defer cancel() } - _, err := a.db.Exec(ctx, `DELETE FROM kvmeta WHERE "key"=$1`, key) + _, err := a.db.Exec(ctx, `DELETE FROM im_kvmeta WHERE "key"=$1`, key) return err } @@ -3597,7 +3597,7 @@ func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error { defer cancel() } - _, err := a.db.Exec(ctx, `DELETE FROM kvmeta WHERE "key" LIKE $1 AND createdat<$2`, keyPrefix+"%", olderThan) + _, err := a.db.Exec(ctx, `DELETE FROM im_kvmeta WHERE "key" LIKE $1 AND createdat<$2`, keyPrefix+"%", olderThan) return err } diff --git a/server/db/postgres/tests/postgres_test.go b/server/db/postgres/tests/postgres_test.go index 1493e3c..57dae1d 100644 --- a/server/db/postgres/tests/postgres_test.go +++ b/server/db/postgres/tests/postgres_test.go @@ -66,12 +66,12 @@ func TestUserCreate(t *testing.T) { } var count int - err := db.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&count) + err := db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users").Scan(&count) if err != nil { t.Error(err) } if count == 0 { - t.Error("No users created!") + t.Error("No im_users created!") } } @@ -97,7 +97,7 @@ func TestCredUpsert(t *testing.T) { t.Error("Should return duplicate error but got", err) } - // Test add new unvalidated credentials + // Test add new unvalidated im_credentials inserted, err := adp.CredUpsert(testData.Creds[3]) if err != nil { t.Fatal(err) @@ -183,7 +183,7 @@ func TestTopicCreateP2P(t *testing.T) { var got types.Subscription var userId int64 var modeWant, modeGiven []byte - err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2", + err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=$1 AND userid=$2", testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt, &got.UpdatedAt, &got.DeletedAt, &userId, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &modeWant, &modeGiven, &got.Private) @@ -231,7 +231,7 @@ func TestMessageSave(t *testing.T) { } } - // Some messages are soft deleted, but it's ignored by adp.MessageSave + // Some im_messages are soft deleted, but it's ignored by adp.MessageSave for _, msg := range testData.Msgs { if len(msg.DeletedFor) > 0 { for _, del := range msg.DeletedFor { @@ -284,7 +284,7 @@ func TestUserGetAll(t *testing.T) { t.Fatal(err) } if len(got) > 0 { - t.Error("result users should be zero length, got", len(got)) + t.Error("result im_users should be zero length, got", len(got)) } got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id)) @@ -516,7 +516,7 @@ func TestChannelsForUser(t *testing.T) { if err != nil { t.Fatal(err) } - // Should return empty slice since we don't have channel subscriptions in test data + // Should return empty slice since we don't have channel im_subscriptions in test data if len(channels) != 0 { t.Error(mismatchErrorString("Channels length", len(channels), 0)) } @@ -663,7 +663,7 @@ func TestUserUpdate(t *testing.T) { UpdatedAt time.Time CreatedAt time.Time } - err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM users WHERE id=$1", + err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM im_users WHERE id=$1", decodeUid(testData.Users[0].Id)).Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt) if err != nil { t.Fatal(err) @@ -726,9 +726,9 @@ func TestUserGetUnvalidated(t *testing.T) { if err != nil { t.Error(err) } - // Should return empty slice since all test users are considered validated + // Should return empty slice since all test im_users are considered validated if len(uids) > 0 { - t.Error("Expected no unvalidated users in test data") + t.Error("Expected no unvalidated im_users in test data") } } @@ -744,7 +744,7 @@ func TestCredFail(t *testing.T) { UpdatedAt time.Time CreatedAt time.Time } - err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM credentials WHERE userid=$1 AND method=$2 AND value=$3", + err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3", decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt) if err != nil { t.Fatal(err) @@ -769,7 +769,7 @@ func TestCredConfirm(t *testing.T) { CreatedAt time.Time Done bool } - err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM credentials WHERE userid=$1 AND method=$2 AND value=$3", + err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3", decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done) if err != nil { t.Fatal(err) @@ -791,7 +791,7 @@ func TestAuthUpdRecord(t *testing.T) { t.Fatal(err) } var got []byte - err = db.QueryRow(ctx, "SELECT secret FROM auth WHERE uname=$1", rec.Unique).Scan(&got) + err = db.QueryRow(ctx, "SELECT secret FROM im_auth WHERE uname=$1", rec.Unique).Scan(&got) if err != nil { t.Fatal(err) } @@ -799,7 +799,7 @@ func TestAuthUpdRecord(t *testing.T) { t.Error(mismatchErrorString("Secret", got, rec.Secret)) } - // Test with auth ID (unique) change + // Test with im_auth ID (unique) change newId := "basic:bob12345" err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId, rec.AuthLvl, newSecret, rec.Expires) @@ -808,12 +808,12 @@ func TestAuthUpdRecord(t *testing.T) { } // Test if old ID deleted var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM auth WHERE uname=$1", rec.Unique).Scan(&count) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_auth WHERE uname=$1", rec.Unique).Scan(&count) if err != nil { t.Fatal(err) } if count != 0 { - t.Error("Old auth record not deleted") + t.Error("Old im_auth record not deleted") } } @@ -832,7 +832,7 @@ func TestTopicUpdateOnMessage(t *testing.T) { TouchedAt time.Time SeqId int } - err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM topics WHERE name=$1", testData.Topics[2].Id). + err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id). Scan(&got.TouchedAt, &got.SeqId) if err != nil { t.Fatal(err) @@ -852,7 +852,7 @@ func TestTopicUpdate(t *testing.T) { t.Fatal(err) } var got time.Time - err = db.QueryRow(ctx, "SELECT updatedat FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got) + err = db.QueryRow(ctx, "SELECT updatedat FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got) if err != nil { t.Fatal(err) } @@ -870,11 +870,11 @@ func TestTopicUpdateSubCnt(t *testing.T) { // Verify the subscription count was updated correctly var subcnt int - err = db.QueryRow(ctx, "SELECT subcnt FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt) + err = db.QueryRow(ctx, "SELECT subcnt FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt) if err != nil { t.Fatal(err) } - // Should match the number of active subscriptions + // Should match the number of active im_subscriptions if subcnt < 0 { t.Error("Subscription count should be non-negative") } @@ -886,7 +886,7 @@ func TestTopicOwnerChange(t *testing.T) { t.Fatal(err) } var got int64 - err = db.QueryRow(ctx, "SELECT owner FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got) + err = db.QueryRow(ctx, "SELECT owner FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got) if err != nil { t.Fatal(err) } @@ -905,7 +905,7 @@ func TestSubsUpdate(t *testing.T) { t.Fatal(err) } var got time.Time - err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 AND userid=$2", + err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 AND userid=$2", testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got) if err != nil { t.Fatal(err) @@ -918,7 +918,7 @@ func TestSubsUpdate(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 LIMIT 1", + err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 LIMIT 1", testData.Topics[1].Id).Scan(&got) if err != nil { t.Fatal(err) @@ -934,7 +934,7 @@ func TestSubsDelete(t *testing.T) { t.Fatal(err) } var deletedat sql.NullTime - err = db.QueryRow(ctx, "SELECT deletedat FROM subscriptions WHERE topic=$1 AND userid=$2", + err = db.QueryRow(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=$1 AND userid=$2", testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat) if err != nil { t.Fatal(err) @@ -957,7 +957,7 @@ func TestDeviceUpsert(t *testing.T) { DeviceId string Platform string } - err = db.QueryRow(ctx, "SELECT deviceid, platform FROM devices WHERE userid=$1 LIMIT 1", + err = db.QueryRow(ctx, "SELECT deviceid, platform FROM im_devices WHERE userid=$1 LIMIT 1", decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform) if err != nil { t.Fatal(err) @@ -972,7 +972,7 @@ func TestDeviceUpsert(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow(ctx, "SELECT platform FROM devices WHERE userid=$1 AND deviceid=$2", + err = db.QueryRow(ctx, "SELECT platform FROM im_devices WHERE userid=$1 AND deviceid=$2", decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform) if err != nil { t.Fatal(err) @@ -1001,7 +1001,7 @@ func TestMessageAttachments(t *testing.T) { } // Check if attachments were linked var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM filemsglinks WHERE msgid=$1", + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=$1", int64(types.ParseUid(testData.Msgs[1].Id))).Scan(&count) if err != nil { t.Fatal(err) @@ -1036,9 +1036,9 @@ func TestDeviceGetAll(t *testing.T) { if count < 1 { t.Fatal(mismatchErrorString("count", count, ">=1")) } - // Test that devices exist for the users + // Test that im_devices exist for the im_users if len(gotDevs) == 0 { - t.Error("Expected devices for users") + t.Error("Expected im_devices for im_users") } } @@ -1048,7 +1048,7 @@ func TestDeviceDelete(t *testing.T) { t.Fatal(err) } var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1 AND deviceid=$2", + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1 AND deviceid=$2", decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&count) if err != nil { t.Fatal(err) @@ -1061,13 +1061,13 @@ func TestDeviceDelete(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1", + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1", decodeUid(testData.Users[2].Id)).Scan(&count) if err != nil { t.Fatal(err) } if count != 0 { - t.Error("All devices not deleted:", count) + t.Error("All im_devices not deleted:", count) } } @@ -1138,7 +1138,7 @@ func TestCredDel(t *testing.T) { t.Fatal(err) } var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count) if err != nil { t.Fatal(err) } @@ -1150,7 +1150,7 @@ func TestCredDel(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE userid=$1", + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE userid=$1", decodeUid(testData.Users[1].Id)).Scan(&count) if err != nil { t.Fatal(err) @@ -1161,7 +1161,7 @@ func TestCredDel(t *testing.T) { } func TestAuthDelScheme(t *testing.T) { - // Test deleting auth scheme + // Test deleting im_auth scheme err := adp.AuthDelScheme(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme) if err != nil { t.Fatal(err) @@ -1207,15 +1207,15 @@ func TestMessageDeleteList(t *testing.T) { t.Fatal(err) } - // Check messages in dellog + // Check im_messages in im_dellog var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM dellog WHERE topic=$1 AND deletedfor=$2", + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_dellog WHERE topic=$1 AND deletedfor=$2", toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count) if err != nil { t.Fatal(err) } if count == 0 { - t.Error("No dellog entries created") + t.Error("No im_dellog entries created") } // Hard delete test @@ -1234,8 +1234,8 @@ func TestMessageDeleteList(t *testing.T) { t.Fatal(err) } - // Check if messages content was cleared - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1 AND content IS NOT NULL", + // Check if im_messages content was cleared + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1 AND content IS NOT NULL", toDel.Topic).Scan(&count) if err != nil { t.Fatal(err) @@ -1248,7 +1248,7 @@ func TestMessageDeleteList(t *testing.T) { if err != nil { t.Fatal(err) } - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1263,7 +1263,7 @@ func TestTopicDelete(t *testing.T) { t.Fatal(err) } var state int - err = db.QueryRow(ctx, "SELECT state FROM topics WHERE name=$1", testData.Topics[1].Id).Scan(&state) + err = db.QueryRow(ctx, "SELECT state FROM im_topics WHERE name=$1", testData.Topics[1].Id).Scan(&state) if err != nil { t.Fatal(err) } @@ -1277,7 +1277,7 @@ func TestTopicDelete(t *testing.T) { } var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&count) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&count) if err != nil { t.Fatal(err) } @@ -1302,7 +1302,7 @@ func TestUserDelete(t *testing.T) { t.Fatal(err) } var state int - err = db.QueryRow(ctx, "SELECT state FROM users WHERE id=$1", + err = db.QueryRow(ctx, "SELECT state FROM im_users WHERE id=$1", decodeUid(testData.Users[0].Id)).Scan(&state) if err != nil { t.Fatal(err) @@ -1316,7 +1316,7 @@ func TestUserDelete(t *testing.T) { t.Fatal(err) } var count int - err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE id=$1", + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users WHERE id=$1", decodeUid(testData.Users[1].Id)).Scan(&count) if err != nil { t.Fatal(err) diff --git a/server/http.go b/server/http.go index 8426c6e..57083fc 100644 --- a/server/http.go +++ b/server/http.go @@ -28,13 +28,13 @@ import ( "github.com/tinode/chat/server/store/types" ) -func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop <-chan bool) error { +func listenAndServe(addr string, handler http.Handler, tlfConf *tls.Config, stop <-chan bool) error { globals.shuttingDown = false httpdone := make(chan bool) server := &http.Server{ - Handler: mux, + Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, WriteTimeout: 90 * time.Second, diff --git a/server/im_business_events.go b/server/im_business_events.go new file mode 100644 index 0000000..0262711 --- /dev/null +++ b/server/im_business_events.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "strings" + "time" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +func stableIMEventID(parts ...any) string { + return types.StableEventID(parts...) +} + +func enqueueIMBusinessEvent(tenantID types.TenantID, eventID, targetType, target string, + actor types.Uid, eventType string, payload any) error { + if tenantID.IsZero() || eventID == "" || target == "" || eventType == "" { + return types.ErrMalformed + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + err = store.HTTPInfrastructure.CreateEvent(tenantID, &types.BusinessEvent{ + EventID: eventID, TargetType: targetType, Target: target, ActorUserID: actor, + EventType: eventType, Payload: encoded, + }) + if err == nil { + dispatchIMBusinessEvents(tenantID) + } + return err +} + +func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, eventType, + title, summary string, action types.KVMap) error { + if uid.IsZero() { + return types.ErrMalformed + } + return store.Security.CreateSystemNotification(tenantID, &types.SystemNotification{ + TenantID: tenantID, UserID: uid, EventID: eventID, EventType: eventType, + Title: title, Summary: summary, Action: action, + }) +} + +func publishIMMeetingEvent(tenantID types.TenantID, actor types.Uid, meeting *types.Meeting, eventType string) error { + if meeting == nil { + return types.ErrMalformed + } + for _, participant := range meeting.Participants { + uid := participant.UserID + if uid.IsZero() { + continue + } + eventID := stableIMEventID("meeting", meeting.MeetingID, meeting.Version, eventType, uid.UserId()) + if uid != actor { + if err := createIMSystemNotification(tenantID, uid, stableIMEventID(eventID, "notification"), eventType, + "会议通知", meeting.Title, types.KVMap{"meeting_id": meeting.MeetingID}); err != nil { + return err + } + } + if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, eventType, + map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}); err != nil { + return err + } + } + return nil +} + +func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error { + if target.IsZero() { + return types.ErrMalformed + } + eventID := stableIMEventID("relation", stableID, eventType, target.UserId()) + if target != actor && title != "" { + if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), eventType, + title, summary, types.KVMap{"user_id": actor.UserId()}); err != nil { + return err + } + } + return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType, + map[string]any{"user_id": actor.UserId()}) +} + +func dispatchIMBusinessEvents(tenantID types.TenantID) { + if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil { + return + } + now := time.Now().UTC().Round(time.Millisecond) + events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now) + if err != nil { + logs.Warn.Printf("business outbox: list tenant=%d: %v", tenantID, err) + return + } + for _, event := range events { + from := "" + if !event.ActorUserID.IsZero() { + from = event.ActorUserID.UserId() + } + payload := json.RawMessage(event.Payload) + if len(payload) == 0 { + payload = json.RawMessage(`{}`) + } + message := &ServerComMessage{ + TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt, + Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload}, + } + select { + case globals.hub.routeSrv <- message: + if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil { + logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err) + return + } + default: + logs.Warn.Printf("business outbox: hub queue full event=%s target=%s", event.EventID, strings.TrimSpace(event.Target)) + return + } + } +} diff --git a/server/im_group_events.go b/server/im_group_events.go new file mode 100644 index 0000000..6cb57e0 --- /dev/null +++ b/server/im_group_events.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +// dispatchIMGroupEvents delivers committed outbox events to online topic +// sessions. Events remain pending when the hub is unavailable or its queue is +// full, so the next successful group mutation retries them. +func dispatchIMGroupEvents(tenantID types.TenantID) { + if tenantID.IsZero() || globals.hub == nil || store.GroupBusiness == nil { + return + } + now := time.Now().UTC().Round(time.Millisecond) + events, err := store.GroupBusiness.ListPendingEvents(tenantID, 100, now) + if err != nil { + logs.Warn.Printf("group outbox: list tenant=%d: %v", tenantID, err) + return + } + for _, event := range events { + members, memberErr := store.Topics.GetUsers(tenantID, event.Topic, &types.QueryOpt{Limit: 1000}) + if memberErr != nil { + logs.Warn.Printf("group outbox: list members event=%s: %v", event.EventID, memberErr) + return + } + for _, member := range members { + uid := types.ParseUid(member.User) + if uid.IsZero() { + if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 { + uid = store.EncodeUid(decoded) + } + } + if uid.IsZero() || uid == event.ActorUserID { + continue + } + if err = createIMSystemNotification(tenantID, uid, + stableIMEventID(event.EventID, uid.UserId(), "notification"), event.EventType, + "群聊动态", event.EventType, types.KVMap{"topic": event.Topic}); err != nil { + logs.Warn.Printf("group outbox: create notification event=%s user=%s: %v", event.EventID, uid.UserId(), err) + return + } + } + payload := json.RawMessage(event.Payload) + if len(payload) == 0 { + payload = json.RawMessage(`{}`) + } + from := "" + if !event.ActorUserID.IsZero() { + from = event.ActorUserID.UserId() + } + message := &ServerComMessage{ + TenantID: tenantID, + RcptTo: event.Topic, + AsUser: from, + Timestamp: event.CreatedAt, + Info: &MsgServerInfo{ + Topic: event.Topic, + From: from, + What: "group", + Event: event.EventType, + Payload: payload, + }, + } + select { + case globals.hub.routeSrv <- message: + if err = store.GroupBusiness.MarkEventDelivered(tenantID, event.EventID, now); err != nil { + logs.Warn.Printf("group outbox: mark delivered event=%s: %v", event.EventID, err) + return + } + default: + logs.Warn.Printf("group outbox: hub queue full event=%s", event.EventID) + return + } + } +} diff --git a/server/im_http.go b/server/im_http.go index cac2c3d..d2f8098 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1,6 +1,8 @@ package main import ( + "bytes" + "context" "crypto/hmac" "crypto/rand" "crypto/sha256" @@ -27,25 +29,29 @@ import ( ) const ( - imReasonAPIKeyRequired = "API_KEY_REQUIRED" - imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED" - imReasonMalformedRequest = "MALFORMED_REQUEST" - imReasonTenantUnavailable = "TENANT_UNAVAILABLE" - imReasonVerificationRate = "VERIFICATION_RATE_LIMITED" - imReasonChallengeExpired = "CHALLENGE_EXPIRED" - imReasonCodeInvalid = "CODE_INVALID" - imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS" - imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION" - imReasonAuthRequired = "AUTH_REQUIRED" - imReasonRealNameRequired = "REALNAME_REQUIRED" - imReasonActionToken = "ACTION_TOKEN_INVALID" - imReasonPermissionDenied = "PERMISSION_DENIED" - imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED" - imReasonInternal = "INTERNAL_ERROR" + imReasonAPIKeyRequired = "API_KEY_REQUIRED" + imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED" + imReasonMalformedRequest = "MALFORMED_REQUEST" + imReasonTenantUnavailable = "TENANT_UNAVAILABLE" + imReasonVerificationRate = "VERIFICATION_RATE_LIMITED" + imReasonChallengeExpired = "CHALLENGE_EXPIRED" + imReasonCodeInvalid = "CODE_INVALID" + imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS" + imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION" + imReasonAuthRequired = "AUTH_REQUIRED" + imReasonRealNameRequired = "REALNAME_REQUIRED" + imReasonActionToken = "ACTION_TOKEN_INVALID" + imReasonPermissionDenied = "PERMISSION_DENIED" + imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED" + imReasonSchemaUnsupported = "SCHEMA_UNSUPPORTED" + imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT" + imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS" + imReasonInternal = "INTERNAL_ERROR" ) const ( imVerificationCodeLength = 6 + imVerificationTestCode = "123456" imVerificationExpiresInSeconds = 300 imVerificationResendAfterSeconds = 60 imVerificationMaxAttempts = 5 @@ -67,6 +73,160 @@ type imHTTPResponse struct { ServerTime string `json:"server_time"` } +type imHTTPContextKey int + +const imHTTPProtocolValidated imHTTPContextKey = iota + +func imReliableHTTPEventsEnabled(req *http.Request) bool { + enabled, _ := req.Context().Value(imHTTPProtocolValidated).(bool) + return enabled +} + +func withIMHTTPProtocol(prefix string, next http.Handler) http.Handler { + return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { + if !strings.HasPrefix(req.URL.Path, prefix) { + next.ServeHTTP(wrt, req) + return + } + + requestID := strings.TrimSpace(req.Header.Get("X-Request-ID")) + if requestID == "" { + if token, err := randomIMToken(18); err == nil { + requestID = "req_" + token + } else { + requestID = fmt.Sprintf("req_%d", time.Now().UTC().UnixNano()) + } + req.Header.Set("X-Request-ID", requestID) + } + wrt.Header().Set("X-Request-ID", requestID) + wrt.Header().Set("X-IM-Schema", "1") + + if valid, _ := checkAPIKey(strings.TrimSpace(req.Header.Get("X-Tinode-APIKey"))); !valid { + writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil) + return + } + if schema := strings.TrimSpace(req.Header.Get("X-IM-Schema")); schema != "1" { + writeIMError(wrt, req, http.StatusBadRequest, 30006, imReasonSchemaUnsupported, "unsupported IM schema", map[string]any{"supported": []int{1}}) + return + } + req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true)) + if req.Method == http.MethodGet || req.Method == http.MethodHead { + if principal, err := resolveIMHTTPBearer(req); err == nil { + dispatchIMBusinessEvents(principal.TenantID) + } + } + serveIMHTTPIdempotent(wrt, req, next) + }) +} + +type imBufferedResponseWriter struct { + header http.Header + status int + wroteHeader bool + body bytes.Buffer +} + +func newIMBufferedResponseWriter() *imBufferedResponseWriter { + return &imBufferedResponseWriter{header: make(http.Header), status: http.StatusOK} +} + +func (w *imBufferedResponseWriter) Header() http.Header { return w.header } + +func (w *imBufferedResponseWriter) WriteHeader(status int) { + if w.wroteHeader { + return + } + w.status = status + w.wroteHeader = true +} + +func (w *imBufferedResponseWriter) Write(data []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.body.Write(data) +} + +func (w *imBufferedResponseWriter) flushTo(wrt http.ResponseWriter) { + for key, values := range w.header { + for _, value := range values { + wrt.Header().Add(key, value) + } + } + wrt.WriteHeader(w.status) + _, _ = wrt.Write(w.body.Bytes()) +} + +func serveIMHTTPIdempotent(wrt http.ResponseWriter, req *http.Request, next http.Handler) { + if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodPatch && req.Method != http.MethodDelete { + next.ServeHTTP(wrt, req) + return + } + if !strings.HasPrefix(strings.TrimSpace(req.Header.Get("Authorization")), "Bearer ") { + next.ServeHTTP(wrt, req) + return + } + principal, err := resolveIMHTTPBearer(req) + if err != nil { + next.ServeHTTP(wrt, req) + return + } + dispatchIMBusinessEvents(principal.TenantID) + key := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + if key == "" || len(key) > 128 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) + return + } + body, err := io.ReadAll(io.LimitReader(req.Body, 4<<20+1)) + if err != nil || len(body) > 4<<20 { + writeIMError(wrt, req, http.StatusRequestEntityTooLarge, 30000, imReasonMalformedRequest, "request body too large", nil) + return + } + req.Body = io.NopCloser(bytes.NewReader(body)) + hashInput := append([]byte(req.URL.RawQuery+"\x00"), body...) + bodyHash := sha256.Sum256(hashInput) + record := &types.HTTPIdempotencyRecord{ + UserID: principal.Uid, Method: req.Method, Route: req.URL.Path, Key: key, + BodyHash: fmt.Sprintf("%x", bodyHash[:]), ExpiresAt: time.Now().UTC().Add(24 * time.Hour), + } + existing, acquired, err := store.HTTPInfrastructure.BeginIdempotency(principal.TenantID, record) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if !acquired { + if existing == nil || existing.BodyHash != record.BodyHash { + writeIMError(wrt, req, http.StatusConflict, 30007, imReasonIdempotencyConflict, "Idempotency-Key was used with a different request", nil) + return + } + if existing.State == types.HTTPIdempotencyCompleted { + wrt.Header().Set("Content-Type", "application/json; charset=utf-8") + wrt.Header().Set("X-Idempotent-Replay", "true") + wrt.WriteHeader(existing.ResponseStatus) + _, _ = wrt.Write(existing.ResponseBody) + return + } + writeIMError(wrt, req, http.StatusConflict, 30008, imReasonIdempotencyPending, "request with this Idempotency-Key is still processing", nil) + return + } + + buffered := newIMBufferedResponseWriter() + next.ServeHTTP(buffered, req) + record.ResponseStatus = buffered.status + record.ResponseBody = append([]byte(nil), buffered.body.Bytes()...) + if buffered.status >= http.StatusInternalServerError { + _ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record) + buffered.flushTo(wrt) + return + } + if err = store.HTTPInfrastructure.CompleteIdempotency(principal.TenantID, record); err != nil { + _ = store.HTTPInfrastructure.AbortIdempotency(principal.TenantID, record) + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + buffered.flushTo(wrt) +} + type imTenantResolveRequest struct { TenantCode string `json:"tenant_code"` Platform string `json:"platform,omitempty"` @@ -438,11 +598,8 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - code, err := randomIMNumericCode(imVerificationCodeLength) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } + // Temporary fixed code for app integration until the SMS provider is connected. + code := imVerificationTestCode vc := &types.VerificationCode{ TenantID: tenant.ID, ChallengeID: challengeID, @@ -764,6 +921,21 @@ func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) { payload["token_action"] = "risk_continue" payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano) } + if imReliableHTTPEventsEnabled(req) { + eventType := "security.risk." + decision + eventID := stableIMEventID("risk", challengeID, decision, challenge.State) + if err = createIMSystemNotification(principal.TenantID, principal.Uid, + stableIMEventID(eventID, "notification"), eventType, "安全操作确认", decision, + types.KVMap{"risk_challenge_id": challengeID}); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "user", principal.Uid.UserId(), + principal.Uid, eventType, map[string]any{"risk_challenge_id": challengeID, "decision": decision}); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, payload) } @@ -1078,8 +1250,8 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "user_search": true, "scan_qr": true, "system_notifications": true, - "message_recall": false, - "message_edit": false, + "message_recall": true, + "message_edit": true, }, "permissions": map[string]any{ "create_group": true, @@ -1095,7 +1267,7 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "message_policy": map[string]any{ "recall_window_seconds": 120, "edit_window_seconds": 300, - "supported_actions": []string{"copy", "quote", "delete"}, + "supported_actions": []string{"copy", "quote", "delete", "recall", "edit"}, }, "security": map[string]any{ "realname": map[string]any{ @@ -1122,11 +1294,16 @@ func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + tenant, err := store.Tenants.GetByID(principal.TenantID) + if err != nil || tenant == nil || strings.TrimSpace(tenant.Code) == "" { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } writeIMSuccess(wrt, req, map[string]any{ "layout_version": layoutVersion, "modules": workbenchModulePayloads(modules), "enterprise": map[string]any{ - "tenant_code": strconv.FormatInt(int64(principal.TenantID), 10), + "tenant_code": tenant.Code, "feature_flags": map[string]any{ "organization": true, "meetings": true, @@ -1273,6 +1450,12 @@ func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.created"); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } @@ -1360,6 +1543,12 @@ func serveIMMeetingUpdate(wrt http.ResponseWriter, req *http.Request, meetingID writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.updated"); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } @@ -1381,6 +1570,12 @@ func serveIMMeetingCancel(wrt http.ResponseWriter, req *http.Request, meetingID writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.canceled"); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } @@ -1402,6 +1597,18 @@ func serveIMMeetingReminders(wrt http.ResponseWriter, req *http.Request, meeting writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + meeting, getErr := store.Meetings.Get(principal.TenantID, principal.Uid, meetingID) + if getErr != nil { + writeIMRelationError(wrt, req, getErr) + return + } + meeting.Participants = participants + if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.reminded"); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, map[string]any{ "meeting_id": meetingID, "reminded": meetingParticipantPayloads(participants), @@ -1429,6 +1636,12 @@ func serveIMMeetingMyResponse(wrt http.ResponseWriter, req *http.Request, meetin writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err = publishIMMeetingEvent(principal.TenantID, principal.Uid, meeting, "meeting.response.updated"); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, meetingPayload(meeting, principal.Uid)) } @@ -2621,7 +2834,19 @@ func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) { break } item := receiptMemberPayload(member) - if member.ReadSeqId >= seq && len(readItems) < limit { + readReceipts := false + memberID := types.ZeroUid + if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 { + memberID = types.Uid(decoded) + } else { + memberID = types.ParseUid(member.User) + } + if !memberID.IsZero() { + if privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, memberID); privacyErr == nil { + readReceipts = privacy.ReadReceipts + } + } + if readReceipts && member.ReadSeqId >= seq && len(readItems) < limit { readItems = append(readItems, item) } if member.RecvSeqId >= seq && len(deliveredItems) < limit { @@ -2677,6 +2902,14 @@ func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) { writeIMMessageMutationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled") + if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, + "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled")) } @@ -2733,6 +2966,14 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) { writeIMMessageMutationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + eventID := stableIMEventID("message", topic, seq, updated.Version, "edited") + if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, + "message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited")) } @@ -2807,6 +3048,15 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } + privacy, err := store.Profiles.GetPrivacy(principal.TenantID, target) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if !imPrivacyAllowsAdd(privacy, body.Source) { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "user does not accept friend requests from this source", nil) + return + } idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) if idempotencyKey == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"}) @@ -2832,6 +3082,13 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID, + "friend.request.created", "新的好友申请", message); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, friendRequestPayload(created)) } @@ -2952,11 +3209,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { Public: public, } topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull) - if err := store.Topics.Create(principal.TenantID, topic, principal.Uid, groupMemberPrivate("", false, false, 1, now)); err != nil { - writeIMRelationError(wrt, req, err) - return - } - if _, err := store.GroupBusiness.CreateSettings(principal.TenantID, &types.GroupSettings{ + settings := &types.GroupSettings{ Topic: topicName, OwnerUserID: principal.Uid, Name: name, @@ -2971,26 +3224,8 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { CreatedBy: principal.Uid, CreatedAt: now, UpdatedAt: now, - }); err != nil { - writeIMRelationError(wrt, req, err) - return } - if _, err := store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{ - Topic: topicName, - UserID: principal.Uid, - Role: types.GroupMemberRoleOwner, - MemberStatus: types.GroupMemberStatusActive, - JoinSource: "create", - JoinedBy: principal.Uid, - JoinedAt: now, - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - writeIMRelationError(wrt, req, err) - return - } - var members []*types.Subscription + memberIDs := make([]types.Uid, 0, len(body.MemberUserIDs)) seen := map[types.Uid]bool{principal.Uid: true} for _, rawID := range body.MemberUserIDs { uid := types.ParseUserId(strings.TrimSpace(rawID)) @@ -2998,52 +3233,25 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { continue } seen[uid] = true - members = append(members, &types.Subscription{ - TenantID: principal.TenantID, - User: uid.String(), - Topic: topicName, - ModeWant: types.ModeCPublic, - ModeGiven: types.ModeCPublic, - Private: groupMemberPrivate("", false, false, 1, now), - }) + memberIDs = append(memberIDs, uid) } - if len(members) > 0 { - if err := store.Subs.Create(principal.TenantID, members...); err != nil { - writeIMRelationError(wrt, req, err) - return - } - for _, sub := range members { - uid := types.ParseUid(sub.User) - if uid.IsZero() { - uid = types.ParseUserId(sub.User) - } - if uid.IsZero() { - continue - } - if _, err := store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{ - Topic: topicName, - UserID: uid, - Role: types.GroupMemberRoleMember, - MemberStatus: types.GroupMemberStatusActive, - JoinSource: "create", - JoinedBy: principal.Uid, - JoinedAt: now, - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - writeIMRelationError(wrt, req, err) - return - } - } - _ = store.Topics.UpdateSubCnt(principal.TenantID, topicName) + memberPrivate := groupMemberPrivate("", false, false, 1, now) + if err := store.GroupBusiness.CreateGroup(principal.TenantID, topic, principal.Uid, memberPrivate, + settings, memberIDs, memberPrivate, now); err != nil { + writeIMRelationError(wrt, req, err) + return } + dispatchIMGroupEvents(principal.TenantID) created, err := store.Topics.Get(principal.TenantID, topicName) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - selfSub, _ := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false) + selfSub, err := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub)) } @@ -3302,7 +3510,14 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string return } patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version} - _, _ = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch) + if _, err = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch); err != nil { + if rollbackErr := store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": group.Public}); rollbackErr != nil { + logs.Warn.Printf("group[%s]: failed to roll back topic profile after business update error: %v", topic, rollbackErr) + } + writeIMRelationError(wrt, req, err) + return + } + dispatchIMGroupEvents(principal.TenantID) updated, err := store.Topics.Get(principal.TenantID, topic) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -3366,7 +3581,14 @@ func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topi writeIMRelationError(wrt, req, err) return } - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}) + if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications}); err != nil { + if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil { + logs.Warn.Printf("group[%s]: failed to roll back member settings: %v", topic, rollbackErr) + } + writeIMRelationError(wrt, req, err) + return + } + dispatchIMGroupEvents(principal.TenantID) selfSub.Private = private writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub)) } @@ -3406,7 +3628,14 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str writeIMRelationError(wrt, req, err) return } - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}) + if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version}); err != nil { + if rollbackErr := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": selfSub.Private}); rollbackErr != nil { + logs.Warn.Printf("group[%s]: failed to roll back member profile: %v", topic, rollbackErr) + } + writeIMRelationError(wrt, req, err) + return + } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)}) } @@ -3423,15 +3652,11 @@ func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil) return } - if err := store.Subs.Delete(principal.TenantID, topic, principal.Uid); err != nil { + if err := store.GroupBusiness.Leave(principal.TenantID, topic, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } - leftAt := time.Now().UTC().Round(time.Millisecond) - leftStatus := types.GroupMemberStatusLeft - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{MemberStatus: &leftStatus, LeftAt: &leftAt}) - _, _ = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{MemberCountDelta: -1}) - _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"}) } @@ -3511,74 +3736,89 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s continue } item["user_id"] = uid.UserId() - if _, err := store.Subs.Get(principal.TenantID, topic, uid, false); err == nil { + now := time.Now().UTC().Round(time.Millisecond) + joined, err := store.GroupBusiness.InviteMember(principal.TenantID, topic, principal.Uid, uid, + strings.TrimSpace(body.Message), strings.TrimSpace(req.Header.Get("Idempotency-Key")), + groupMemberPrivate("", false, false, 1, now), now) + if err != nil { + item["status"] = "failed" + item["reason"] = relationErrorReason(err) + results = append(results, item) + continue + } + if !joined { item["status"] = "already_member" results = append(results, item) continue } - if err := store.Subs.Create(principal.TenantID, &types.Subscription{ - TenantID: principal.TenantID, - User: uid.String(), - Topic: topic, - ModeWant: types.ModeCPublic, - ModeGiven: types.ModeCPublic, - Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)), - }); err != nil { - item["status"] = "failed" - item["reason"] = relationErrorReason(err) - } else { - now := time.Now().UTC().Round(time.Millisecond) - if _, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{ - InvitationID: "ginv_" + store.Store.GetUidString(), - Topic: topic, - InviterUserID: principal.Uid, - TargetUserID: uid, - Message: strings.TrimSpace(body.Message), - Status: types.GroupInvitationStatusJoined, - Result: "joined", - ExpiresAt: now.Add(7 * 24 * time.Hour), - HandledAt: &now, - CreatedAt: now, - UpdatedAt: now, - IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), - }); err != nil { - item["status"] = "failed" - item["reason"] = relationErrorReason(err) - results = append(results, item) - continue - } - if _, err := store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{ - Topic: topic, - UserID: uid, - Role: types.GroupMemberRoleMember, - MemberStatus: types.GroupMemberStatusActive, - JoinSource: "invitation", - JoinedBy: principal.Uid, - JoinedAt: now, - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - item["status"] = "failed" - item["reason"] = relationErrorReason(err) - results = append(results, item) - continue - } - item["status"] = "joined" - } + item["status"] = "joined" results = append(results, item) } - _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results}) } func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) { - principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, req.Method == http.MethodGet) + if req.Method == http.MethodPost { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + if types.GetTopicCat(topic) != types.TopicCatGrp { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return + } + settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) + if err != nil { + if errors.Is(err, types.ErrNotFound) { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) + } else { + writeIMRelationError(wrt, req, err) + } + return + } + if settings == nil || settings.GroupStatus != types.GroupStatusActive { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) + return + } + sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if sub != nil && (sub.ModeGiven & sub.ModeWant).IsJoiner() { + writeIMError(wrt, req, http.StatusConflict, 30006, "ALREADY_GROUP_MEMBER", "already a group member", nil) + return + } + var body imGroupJoinRequestCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + now := time.Now().UTC().Round(time.Millisecond) + requestID := "gjr_" + store.Store.GetUidString() + item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{ + RequestID: requestID, Topic: topic, ApplicantUserID: principal.Uid, + Source: defaultString(strings.TrimSpace(body.Source), "manual"), Message: strings.TrimSpace(body.Message), + Status: types.GroupJoinRequestStatusPending, IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), + Version: 1, CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + dispatchIMGroupEvents(principal.TenantID) + writeIMSuccess(wrt, req, groupJoinRequestPayload(item)) + return + } + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) if !ok { return } - switch req.Method { - case http.MethodGet: + if req.Method == http.MethodGet { if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return @@ -3596,31 +3836,6 @@ func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic items = nil } writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupJoinRequestPayloads(items), "next_cursor": nextCursor}) - case http.MethodPost: - var body imGroupJoinRequestCreateRequest - if !decodeIMJSONBody(wrt, req, &body) { - return - } - now := time.Now().UTC().Round(time.Millisecond) - requestID := "gjr_" + store.Store.GetUidString() - item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{ - RequestID: requestID, - Topic: topic, - ApplicantUserID: principal.Uid, - Source: defaultString(strings.TrimSpace(body.Source), "manual"), - Message: strings.TrimSpace(body.Message), - Status: types.GroupJoinRequestStatusPending, - Version: 1, - CreatedAt: now, - UpdatedAt: now, - }) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } - writeIMSuccess(wrt, req, groupJoinRequestPayload(item)) - default: - writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) } } @@ -3646,27 +3861,14 @@ func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, t if !decodeIMJSONBody(wrt, req, &body) { return } - updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid, action, "", body.Version, time.Now().UTC().Round(time.Millisecond)) + now := time.Now().UTC().Round(time.Millisecond) + updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid, + action, "", body.Version, groupMemberPrivate("", false, false, 1, now), now) if err != nil { writeIMRelationError(wrt, req, err) return } - if action == "accept" { - uid := updated.ApplicantUserID - if !uid.IsZero() { - _ = store.Subs.Create(principal.TenantID, &types.Subscription{ - TenantID: principal.TenantID, - User: uid.String(), - Topic: topic, - ModeWant: types.ModeCPublic, - ModeGiven: types.ModeCPublic, - Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)), - }) - now := time.Now().UTC().Round(time.Millisecond) - _, _ = store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: principal.Uid, JoinedAt: now, Version: 1, CreatedAt: now, UpdatedAt: now}) - _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) - } - } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupJoinRequestPayload(updated)) } @@ -3729,6 +3931,7 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic writeIMRelationError(wrt, req, err) return } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupAnnouncementPayload(announcement)) case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost: announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read") @@ -3789,6 +3992,7 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st writeIMRelationError(wrt, req, err) return } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub)) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -3855,17 +4059,21 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail continue } item["user_id"] = uid.UserId() - if err := store.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil { + if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid, + types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil { item["ok"] = false item["reason"] = relationErrorReason(err) } else { - role := types.GroupMemberRoleAdmin - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, uid, types.GroupMemberPatch{Role: &role}) item["ok"] = true } results = append(results, item) } - members, _ = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + members, err = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)}) case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete: userID := strings.TrimPrefix(tail, "admins/") @@ -3874,12 +4082,12 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } - if err := store.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCPublic}); err != nil { + if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid, + types.GroupMemberRoleMember, types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } - role := types.GroupMemberRoleMember - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, uid, types.GroupMemberPatch{Role: &role}) + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)}) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -3913,20 +4121,12 @@ func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil) return } - if err = store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil { + if err = store.GroupBusiness.TransferOwner(principal.TenantID, topic, principal.Uid, target, + body.Version, time.Now().UTC().Round(time.Millisecond)); err != nil { writeIMRelationError(wrt, req, err) return } - if err = store.Subs.Update(principal.TenantID, topic, target, map[string]any{"ModeGiven": types.ModeCFull}); err != nil { - writeIMRelationError(wrt, req, err) - return - } - _ = store.Topics.OwnerChange(principal.TenantID, topic, target) - adminRole := types.GroupMemberRoleAdmin - ownerRole := types.GroupMemberRoleOwner - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Role: &adminRole}) - _, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, target, types.GroupMemberPatch{Role: &ownerRole}) - _, _ = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{OwnerUserID: &target, Version: body.Version}) + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1}) } @@ -3976,6 +4176,7 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s } mutes = append(mutes, *mute) } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)}) case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete: uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/"))) @@ -3987,6 +4188,7 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s writeIMRelationError(wrt, req, err) return } + dispatchIMGroupEvents(principal.TenantID) writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false}) default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -4156,10 +4358,20 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + privacy, err := store.Profiles.GetPrivacy(principal.TenantID, peer) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + canAdd := ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "qr") + action := imRelationshipAction(ctx.Relationship) + if ctx.Relationship == types.RelationshipNone && !canAdd { + action = "none" + } writeIMSuccess(wrt, req, map[string]any{ "purpose": claims.Purpose, "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), - "user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: ctx.Relationship == types.RelationshipNone, Action: imRelationshipAction(ctx.Relationship)}), + "user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: canAdd, Action: action}), "source": "qr", "source_topic": "", }) @@ -4234,6 +4446,15 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types writeIMRelationError(wrt, req, err) return } + privacy, err := store.Profiles.GetPrivacy(principal.TenantID, userID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + if !imPrivacyAllowsProfile(privacy, ctx.Relationship) { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil) + return + } writeIMSuccess(wrt, req, userProfilePayload(user, ctx)) } @@ -4262,6 +4483,19 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + eventType := "friend.request." + status + if err = publishIMRelationEvent(principal.TenantID, principal.Uid, updated.ApplicantID, + updated.RequestID+":"+status, eventType, "好友申请已处理", status); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if err = publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, + updated.RequestID+":"+status+":self", eventType, "", ""); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } writeIMSuccess(wrt, req, friendRequestPayload(updated)) } @@ -4285,11 +4519,25 @@ func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, + stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", ""); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } case http.MethodDelete: if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, + stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", ""); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return @@ -4380,6 +4628,19 @@ func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId()) + if err := publishIMRelationEvent(principal.TenantID, principal.Uid, peer, stableID, + "friend.deleted", "好友关系已解除", ""); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableID+":self", + "friend.deleted", "", ""); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } case http.MethodPatch: var body imFriendUpdateRequest if !decodeIMJSONBody(wrt, req, &body) { @@ -4389,6 +4650,13 @@ func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + if imReliableHTTPEventsEnabled(req) { + if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, + stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", ""); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return @@ -4558,23 +4826,33 @@ func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID } func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) { + principal, err := resolveIMHTTPBearer(req) + if err != nil { + if errors.Is(err, types.ErrUnsupported) { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + } else { + writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) + } + return nil, false + } + return principal, true +} + +func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) { authz := strings.TrimSpace(req.Header.Get("Authorization")) token, ok := strings.CutPrefix(authz, "Bearer ") if !ok || strings.TrimSpace(token) == "" || store.Store == nil { - writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) - return nil, false + return nil, types.ErrPermissionDenied } handler := store.Store.GetLogicalAuthHandler("token") if handler == nil || !handler.IsInitialized() { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return nil, false + return nil, types.ErrUnsupported } rec, _, err := handler.Authenticate(auth.AuthContext{}, []byte(strings.TrimSpace(token))) if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() { - writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) - return nil, false + return nil, types.ErrPermissionDenied } - return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, true + return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, nil } func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { @@ -4608,11 +4886,8 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - code, err := randomIMNumericCode(imVerificationCodeLength) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } + // Temporary fixed code for app integration until the SMS provider is connected. + code := imVerificationTestCode vc := &types.VerificationCode{ TenantID: principal.TenantID, ChallengeID: challengeID, @@ -5826,6 +6101,61 @@ func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any { } } +func imPrivacyAllowsAdd(settings *types.UserPrivacySettings, source string) bool { + if settings == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(source)) { + case "phone", "qr", "group": + source = strings.ToLower(strings.TrimSpace(source)) + default: + source = "account" + } + allowed, exists := settings.AddMeMethods[source] + return !exists || allowed +} + +func imPrivacyAllowsProfile(settings *types.UserPrivacySettings, relationship string) bool { + if settings == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(settings.ProfileVisibility)) { + case "everyone": + return true + case "friends", "": + return relationship == types.RelationshipFriend + default: + return false + } +} + +func imCanViewLastSeen(tenantID types.TenantID, viewer, target types.Uid) bool { + if viewer.IsZero() || target.IsZero() { + return false + } + if viewer == target { + return true + } + // Legacy deployments without the product privacy stores retain Tinode's + // original presence behavior. + if store.Profiles == nil || store.Relations == nil { + return true + } + settings, err := store.Profiles.GetPrivacy(tenantID, target) + if err != nil || settings == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(settings.LastSeenVisibility)) { + case "everyone": + return true + case "friends", "": + ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, target, viewer.P2PName(target)) + return err == nil && ctx != nil && ctx.Relationship == types.RelationshipFriend + default: + return false + } +} + func appReleasePayload(release *types.AppRelease) map[string]any { return map[string]any{ "platform": release.Platform, @@ -6163,6 +6493,7 @@ func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic stri writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return nil, nil, false } + dispatchIMGroupEvents(principal.TenantID) sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) diff --git a/server/im_http_test.go b/server/im_http_test.go index f2a5380..3dcbb49 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -33,6 +34,57 @@ type testVerificationStore struct { tokenHash string } +type testHTTPInfrastructureStore struct { + records map[string]*types.HTTPIdempotencyRecord + events []types.BusinessEvent +} + +func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string { + return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key +} + +func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) { + if s.records == nil { + s.records = make(map[string]*types.HTTPIdempotencyRecord) + } + key := s.recordKey(record) + if existing := s.records[key]; existing != nil { + copy := *existing + copy.ResponseBody = append([]byte(nil), existing.ResponseBody...) + return ©, false, nil + } + copy := *record + copy.State = types.HTTPIdempotencyProcessing + s.records[key] = © + return ©, true, nil +} + +func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error { + copy := *record + copy.State = types.HTTPIdempotencyCompleted + copy.ResponseBody = append([]byte(nil), record.ResponseBody...) + s.records[s.recordKey(record)] = © + return nil +} + +func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error { + delete(s.records, s.recordKey(record)) + return nil +} + +func (s *testHTTPInfrastructureStore) CreateEvent(_ types.TenantID, event *types.BusinessEvent) error { + s.events = append(s.events, *event) + return nil +} + +func (s *testHTTPInfrastructureStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.BusinessEvent, error) { + return append([]types.BusinessEvent(nil), s.events...), nil +} + +func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error { + return nil +} + type testRelationsStore struct { context *types.RelationshipContext created *types.FriendRequest @@ -149,6 +201,12 @@ type testGroupBusinessStore struct { updatedMembers []types.GroupMemberPatch } +func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any, + settings *types.GroupSettings, _ []types.Uid, _ any, _ time.Time) error { + s.settings = settings + return nil +} + func (s *testGroupBusinessStore) CreateSettings(_ types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) { s.settings = settings return settings, nil @@ -225,7 +283,7 @@ func (s *testGroupBusinessStore) ListJoinRequests(_ types.TenantID, _ string, st return out, "", nil } -func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, now time.Time) (*types.GroupJoinRequest, error) { +func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, _ any, now time.Time) (*types.GroupJoinRequest, error) { status := types.GroupJoinRequestStatusRejected if action == "accept" { status = types.GroupJoinRequestStatusAccepted @@ -267,6 +325,61 @@ func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types. return nil } +func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid, + message, idempotencyKey string, _ any, now time.Time) (bool, error) { + for _, invitation := range s.invitations { + if invitation.Topic == topic && invitation.TargetUserID == target { + return false, nil + } + } + s.invitations = append(s.invitations, types.GroupInvitation{Topic: topic, InviterUserID: inviter, + TargetUserID: target, Message: message, IdempotencyKey: idempotencyKey, + Status: types.GroupInvitationStatusJoined, CreatedAt: now, UpdatedAt: now}) + return true, nil +} + +func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, _ time.Time) error { + return nil +} + +func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid, + role string, _ types.AccessMode, _ time.Time) error { + s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role}) + s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive} + return nil +} + +func (s *testGroupBusinessStore) TransferOwner(_ types.TenantID, topic string, _, target types.Uid, _ int, _ time.Time) error { + settings, _ := s.GetSettings(0, topic) + settings.OwnerUserID = target + s.settings = settings + return nil +} + +func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) { + if admin { + return true, nil + } + if s.settings != nil && s.settings.Topic == topic && s.settings.MuteAll { + return false, nil + } + for _, mute := range s.mutes { + if mute.Topic == topic && mute.UserID == uid && mute.Status == types.GroupMuteStatusActive && + (mute.ExpiresAt == nil || mute.ExpiresAt.After(now)) { + return false, nil + } + } + return true, nil +} + +func (s *testGroupBusinessStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.GroupEvent, error) { + return nil, nil +} + +func (s *testGroupBusinessStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error { + return nil +} + func defaultStringPtr(value *string, fallback string) string { if value == nil { return fallback @@ -501,6 +614,12 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch if patch.AddMeMethods != nil { privacy.AddMeMethods = patch.AddMeMethods } + if patch.ProfileVisibility != nil { + privacy.ProfileVisibility = *patch.ProfileVisibility + } + if patch.LastSeenVisibility != nil { + privacy.LastSeenVisibility = *patch.LastSeenVisibility + } if patch.ReadReceipts != nil { privacy.ReadReceipts = *patch.ReadReceipts } @@ -821,6 +940,84 @@ func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantI return mockStore } +func TestIMHTTPProtocolHeaders(t *testing.T) { + apiKey := makeTestAPIKey(t) + called := 0 + handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { + called++ + writeIMSuccess(wrt, req, map[string]any{"ok": true}) + })) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || called != 0 { + t.Fatalf("missing API key: status=%d called=%d", rec.Code, called) + } + if rec.Header().Get("X-Request-ID") == "" { + t.Fatal("server did not generate X-Request-ID") + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) + req.Header.Set("X-Tinode-APIKey", apiKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported { + t.Fatalf("missing schema: status=%d body=%s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) + req.Header.Set("X-Tinode-APIKey", apiKey) + req.Header.Set("X-IM-Schema", "1") + req.Header.Set("X-Request-ID", "request-1") + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" { + t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID")) + } +} + +func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldInfrastructure := store.HTTPInfrastructure + infrastructure := &testHTTPInfrastructureStore{} + store.HTTPInfrastructure = infrastructure + defer func() { store.HTTPInfrastructure = oldInfrastructure }() + + called := 0 + handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { + called++ + body, _ := io.ReadAll(req.Body) + writeIMSuccess(wrt, req, map[string]any{"body": string(body), "call": called}) + })) + apiKey := makeTestAPIKey(t) + request := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("X-Tinode-APIKey", apiKey) + req.Header.Set("X-IM-Schema", "1") + req.Header.Set("Idempotency-Key", "same-key") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + + first := request(`{"value":1}`) + second := request(`{"value":1}`) + if first.Code != http.StatusOK || second.Code != http.StatusOK || called != 1 { + t.Fatalf("replay failed: first=%d second=%d called=%d", first.Code, second.Code, called) + } + if second.Header().Get("X-Idempotent-Replay") != "true" || first.Body.String() != second.Body.String() { + t.Fatalf("response was not replayed: first=%s second=%s", first.Body.String(), second.Body.String()) + } + conflict := request(`{"value":2}`) + if conflict.Code != http.StatusConflict || decodeIMHTTPResponse(t, conflict).Reason != imReasonIdempotencyConflict || called != 1 { + t.Fatalf("conflict failed: status=%d called=%d body=%s", conflict.Code, called, conflict.Body.String()) + } +} + func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ @@ -1364,11 +1561,16 @@ func TestServeIMWorkbench(t *testing.T) { defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldWorkbench := store.Workbench + oldTenants := store.Tenants store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{ {Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"}, {Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"}, }} - defer func() { store.Workbench = oldWorkbench }() + store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}} + defer func() { + store.Workbench = oldWorkbench + store.Tenants = oldTenants + }() req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil) req.Header.Set("Authorization", "Bearer token-1") @@ -1391,6 +1593,10 @@ func TestServeIMWorkbench(t *testing.T) { if item["code"] != "organization" || item["route"] != "/organization" { t.Fatalf("unexpected module: %#v", item) } + enterprise := data["enterprise"].(map[string]any) + if enterprise["tenant_code"] != "acme" { + t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"]) + } } func TestServeIMOrganizationDepartments(t *testing.T) { @@ -1838,6 +2044,43 @@ func TestServeIMMePrivacy(t *testing.T) { } } +func TestIMCanViewLastSeen(t *testing.T) { + oldProfiles := store.Profiles + oldRelations := store.Relations + defer func() { + store.Profiles = oldProfiles + store.Relations = oldRelations + }() + + viewer, target := types.Uid(1), types.Uid(2) + profiles := &testProfilesStore{privacy: &types.UserPrivacySettings{ + UserID: target, LastSeenVisibility: "everyone", + }} + store.Profiles = profiles + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipNone, + }} + if !imCanViewLastSeen(7, viewer, target) { + t.Fatal("everyone visibility must expose last seen") + } + + profiles.privacy.LastSeenVisibility = "nobody" + if imCanViewLastSeen(7, viewer, target) { + t.Fatal("nobody visibility must hide last seen") + } + + profiles.privacy.LastSeenVisibility = "friends" + if imCanViewLastSeen(7, viewer, target) { + t.Fatal("non-friend must not see friends-only last seen") + } + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipFriend, + }} + if !imCanViewLastSeen(7, viewer, target) { + t.Fatal("friend must see friends-only last seen") + } +} + func TestServeIMAppReleaseLatest(t *testing.T) { oldTenants := store.Tenants oldReleases := store.AppReleases @@ -2118,13 +2361,16 @@ func TestServeIMMessageReceipts(t *testing.T) { expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldTopics := store.Topics + oldProfiles := store.Profiles subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Subs = subs store.Topics = topics + store.Profiles = &testProfilesStore{} defer func() { store.Subs = oldSubs store.Topics = oldTopics + store.Profiles = oldProfiles }() mySub := &types.Subscription{ @@ -2326,15 +2572,6 @@ func TestServeIMGroupCreate(t *testing.T) { store.GroupBusiness = oldGroupBusiness }() - topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn( - func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error { - if topic.Id != "grpnewgroup" { - t.Fatalf("unexpected topic id: %s", topic.Id) - } - return nil - }) - subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil) - topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil) topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{ ObjHeader: types.ObjHeader{Id: "grpnewgroup"}, TenantID: 7, @@ -2475,9 +2712,6 @@ func TestServeIMGroupLeave(t *testing.T) { ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) - subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil) - topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(nil) - req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() @@ -2493,6 +2727,46 @@ func TestServeIMGroupLeave(t *testing.T) { } } +func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldGroupBusiness := store.GroupBusiness + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + groupStore := &testGroupBusinessStore{} + store.Subs = subs + store.GroupBusiness = groupStore + defer func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + }() + owner := types.Uid(42) + target := types.Uid(43) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{ + TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", + bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "invite-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" { + t.Fatalf("unexpected invitation response: %#v", data) + } + if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target { + t.Fatalf("invitation was not persisted: %#v", groupStore.invitations) + } +} + func TestServeIMGroupQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -2595,7 +2869,8 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) { store.GroupBusiness = oldGroupBusiness }() selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} - subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(2) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(nil, nil) annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`)) annReq.Header.Set("Authorization", "Bearer token-1") @@ -2663,10 +2938,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { members := []types.Subscription{*ownerSub, member} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3) topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2) - subs.EXPECT().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil) - subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil) - topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(nil) adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`)) adminReq.Header.Set("Authorization", "Bearer token-1") @@ -3236,6 +3508,7 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) { store.Store = mockStore oldUsers := store.Users oldRelations := store.Relations + oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users owner := types.Uid(1) @@ -3258,10 +3531,12 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) { Relationship: types.RelationshipNone, CanSend: false, }} + store.Profiles = &testProfilesStore{} defer func() { store.Store = oldStore store.Users = oldUsers store.Relations = oldRelations + store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`)) @@ -3447,9 +3722,14 @@ func TestServeIMFriendRequestCreate(t *testing.T) { mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1) mockStore.EXPECT().GetUidString().Return("request-1") oldRelations := store.Relations + oldProfiles := store.Profiles relations := &testRelationsStore{} store.Relations = relations - defer func() { store.Relations = oldRelations }() + store.Profiles = &testProfilesStore{} + defer func() { + store.Relations = oldRelations + store.Profiles = oldProfiles + }() req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests", bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`)) @@ -3643,6 +3923,7 @@ func TestServeIMUserProfile(t *testing.T) { expectIMBearer(t, ctrl, "token-1", 7, 1) oldUsers := store.Users oldRelations := store.Relations + oldProfiles := store.Profiles users := mock_store.NewMockUsersPersistenceInterface(ctrl) peer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ @@ -3659,9 +3940,11 @@ func TestServeIMUserProfile(t *testing.T) { Relationship: types.RelationshipFriend, CanSend: true, }} + store.Profiles = &testProfilesStore{} defer func() { store.Users = oldUsers store.Relations = oldRelations + store.Profiles = oldProfiles }() req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil) diff --git a/server/main.go b/server/main.go index 7e82f28..010e4c3 100644 --- a/server/main.go +++ b/server/main.go @@ -853,7 +853,7 @@ func main() { mux.HandleFunc("/", serve404) } - if err = listenAndServe(config.Listen, mux, tlsConfig, signalHandler()); err != nil { + if err = listenAndServe(config.Listen, withIMHTTPProtocol(config.ApiPath+"v1/im/", mux), tlsConfig, signalHandler()); err != nil { logs.Err.Fatal(err) } } diff --git a/server/push/fcm/README.md b/server/push/fcm/README.md index 0460895..ad53ece 100644 --- a/server/push/fcm/README.md +++ b/server/push/fcm/README.md @@ -17,6 +17,13 @@ This adapter requires you to obtain your own credentials from Goole Firebase. If Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa. 4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications +The server may also enable FCM without storing credentials in `tinode.conf`: + +* `IM_FCM_CREDENTIALS_FILE` points to the service-account JSON file. +* `IM_FCM_CREDENTIALS_JSON` contains the service-account JSON directly. + +Either variable enables the adapter even when the checked-in configuration has `enabled: false`. + ### iOS and Android 1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store. diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index e76c932..2e0a710 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -123,7 +123,7 @@ func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Me skipDevices[deviceID] = struct{}{} } } - devices, count, err = store.Devices.GetAll(rcpt.TenantID, uids...) + devices, count, err = pushDevicesForUsers(rcpt.TenantID, uids...) if err != nil { logs.Warn.Println("fcm push: db error", err) return nil, nil @@ -218,7 +218,7 @@ func TenantChannelName(tenantID t.TenantID, channel string) string { // DevicesForUser loads device IDs of the given user. func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string { - ddef, count, err := store.Devices.GetAll(tenantID, uid) + ddef, count, err := pushDevicesForUsers(tenantID, uid) if err != nil { logs.Warn.Println("fcm devices for user: db error", err) return nil @@ -235,6 +235,19 @@ func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string { return devices } +func pushDevicesForUsers(tenantID t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { + if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenQueryInterface); ok { + devices, count, err := tokens.GetAll(tenantID, uids...) + if err != nil { + return nil, 0, err + } + if count > 0 { + return devices, count, nil + } + } + return store.Devices.GetAll(tenantID, uids...) +} + // ChannelsForUser loads user's channel subscriptions with P permission. func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string { channels, err := store.Users.GetChannels(tenantID, uid) diff --git a/server/push/fcm/payload_devices_test.go b/server/push/fcm/payload_devices_test.go new file mode 100644 index 0000000..7411b70 --- /dev/null +++ b/server/push/fcm/payload_devices_test.go @@ -0,0 +1,65 @@ +package fcm + +import ( + "testing" + + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +type testProductPushTokens struct { + devices map[types.Uid][]types.DeviceDef +} + +func (s *testProductPushTokens) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) { + return nil, nil +} + +func (s *testProductPushTokens) GetAll(_ types.TenantID, _ ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { + count := 0 + for _, devices := range s.devices { + count += len(devices) + } + return s.devices, count, nil +} + +type testLegacyPushDevices struct { + called bool +} + +func (*testLegacyPushDevices) Update(types.TenantID, types.Uid, string, *types.DeviceDef) error { + return nil +} + +func (s *testLegacyPushDevices) GetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { + s.called = true + return nil, 0, nil +} + +func (*testLegacyPushDevices) Delete(types.TenantID, types.Uid, string) error { return nil } + +func TestPushDevicesForUsersPrefersProductTokens(t *testing.T) { + oldTokens := store.DevicePushTokens + oldDevices := store.Devices + defer func() { + store.DevicePushTokens = oldTokens + store.Devices = oldDevices + }() + uid := types.Uid(42) + legacy := &testLegacyPushDevices{} + store.DevicePushTokens = &testProductPushTokens{devices: map[types.Uid][]types.DeviceDef{ + uid: {{TenantID: 7, DeviceId: "product-token", Platform: "android"}}, + }} + store.Devices = legacy + + devices, count, err := pushDevicesForUsers(7, uid) + if err != nil { + t.Fatal(err) + } + if count != 1 || devices[uid][0].DeviceId != "product-token" { + t.Fatalf("unexpected devices: %#v count=%d", devices, count) + } + if legacy.called { + t.Fatal("legacy device store must not be read when product tokens exist") + } +} diff --git a/server/push/fcm/push_fcm.go b/server/push/fcm/push_fcm.go index 4380dc5..90a46c7 100644 --- a/server/push/fcm/push_fcm.go +++ b/server/push/fcm/push_fcm.go @@ -70,6 +70,14 @@ func (Handler) Init(jsonconf json.RawMessage) (bool, error) { return false, errors.New("failed to parse config: " + err.Error()) } + if raw := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_JSON")); raw != "" { + config.Credentials = json.RawMessage(raw) + config.Enabled = true + } + if path := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_FILE")); path != "" { + config.CredentialsFile = path + config.Enabled = true + } if !config.Enabled { return false, nil } diff --git a/server/session_test.go b/server/session_test.go index aa36bd4..c218fd7 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -23,6 +23,10 @@ func (s testTenantStore) GetByCode(string) (*types.Tenant, error) { return s.tenant, s.err } +func (s testTenantStore) GetByID(types.TenantID) (*types.Tenant, error) { + return s.tenant, s.err +} + func test_makeSession(uid types.Uid) *Session { return &Session{ send: make(chan any, 10), diff --git a/server/store/infrastructure.go b/server/store/infrastructure.go new file mode 100644 index 0000000..fcbb959 --- /dev/null +++ b/server/store/infrastructure.go @@ -0,0 +1,81 @@ +package store + +import ( + "time" + + adapter "github.com/tinode/chat/server/db" + "github.com/tinode/chat/server/store/types" +) + +type HTTPInfrastructurePersistenceInterface interface { + BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) + CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error + AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error + CreateEvent(types.TenantID, *types.BusinessEvent) error + ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error) + MarkEventDelivered(types.TenantID, string, time.Time) error +} + +type httpInfrastructureMapper struct{} + +var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{} + +func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) { + business, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + infrastructure, ok := business.(adapter.TenantHTTPInfrastructureAdapter) + if !ok { + return nil, types.ErrUnsupported + } + return infrastructure, nil +} + +func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) { + infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID) + if err != nil { + return nil, false, err + } + return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record) +} + +func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error { + infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID) + if err != nil { + return err + } + return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record) +} + +func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error { + infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID) + if err != nil { + return err + } + return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record) +} + +func (httpInfrastructureMapper) CreateEvent(tenantID types.TenantID, event *types.BusinessEvent) error { + infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID) + if err != nil { + return err + } + return infrastructure.TenantBusinessEventCreate(tenantID, event) +} + +func (httpInfrastructureMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.BusinessEvent, error) { + infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID) + if err != nil { + return nil, err + } + return infrastructure.TenantBusinessEventListPending(tenantID, limit, now) +} + +func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error { + infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID) + if err != nil { + return err + } + return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now) +} diff --git a/server/store/store.go b/server/store/store.go index 977c8f1..0515bd2 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -129,6 +129,7 @@ type storeObj struct{} // TenantsPersistenceInterface defines tenant registry lookups used before authentication. type TenantsPersistenceInterface interface { GetByCode(code string) (*types.Tenant, error) + GetByID(id types.TenantID) (*types.Tenant, error) } type tenantsMapper struct{} @@ -145,6 +146,18 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) { return tenantAdp.TenantGetByCode(code) } +// GetByID resolves an internal tenant ID to its registry record. +func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) { + if !id.IsValid() { + return nil, types.ErrMalformed + } + tenantAdp, ok := adp.(adapter.TenantAdapter) + if !ok { + return nil, types.ErrUnsupported + } + return tenantAdp.TenantGetByID(id) +} + func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) { if !tenantID.IsValid() { return nil, types.ErrMalformed @@ -1080,6 +1093,7 @@ func (callsMapper) List(tenantID types.TenantID, uid types.Uid, topic, cursor st } type GroupBusinessPersistenceInterface interface { + CreateGroup(types.TenantID, *types.Topic, types.Uid, any, *types.GroupSettings, []types.Uid, any, time.Time) error CreateSettings(types.TenantID, *types.GroupSettings) (*types.GroupSettings, error) GetSettings(types.TenantID, string) (*types.GroupSettings, error) UpdateSettings(types.TenantID, string, types.GroupSettingsPatch) (*types.GroupSettings, error) @@ -1090,19 +1104,41 @@ type GroupBusinessPersistenceInterface interface { CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error) CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error) ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error) - ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, time.Time) (*types.GroupJoinRequest, error) + ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error) LatestAnnouncement(types.TenantID, string) (*types.GroupAnnouncement, error) PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error) ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error) UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error) DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error + InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error) + Leave(types.TenantID, string, types.Uid, time.Time) error + UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error + TransferOwner(types.TenantID, string, types.Uid, types.Uid, int, time.Time) error + CanPost(types.TenantID, string, types.Uid, bool, time.Time) (bool, error) + ListPendingEvents(types.TenantID, int, time.Time) ([]types.GroupEvent, error) + MarkEventDelivered(types.TenantID, string, time.Time) error } type groupBusinessMapper struct{} var GroupBusiness GroupBusinessPersistenceInterface +func (groupBusinessMapper) CreateGroup(tenantID types.TenantID, topic *types.Topic, owner types.Uid, ownerPrivate any, + settings *types.GroupSettings, members []types.Uid, memberPrivate any, now time.Time) error { + if topic == nil || topic.TenantID != tenantID || owner.IsZero() || settings == nil || settings.Topic != topic.Id { + return types.ErrMalformed + } + topic.InitTimes() + topic.TouchedAt = topic.CreatedAt + topic.Owner = owner.String() + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantGroupCreate(tenantID, topic, owner, ownerPrivate, settings, members, memberPrivate, now) +} + func (groupBusinessMapper) CreateSettings(tenantID types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) { if settings == nil || settings.Topic == "" || settings.OwnerUserID.IsZero() || settings.CreatedBy.IsZero() { return nil, types.ErrMalformed @@ -1219,7 +1255,7 @@ func (groupBusinessMapper) ListJoinRequests(tenantID types.TenantID, topic, stat return tenantAdp.TenantGroupJoinRequestList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(status), strings.TrimSpace(cursor), limit) } -func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, now time.Time) (*types.GroupJoinRequest, error) { +func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*types.GroupJoinRequest, error) { if strings.TrimSpace(topic) == "" || strings.TrimSpace(requestID) == "" || actor.IsZero() || now.IsZero() { return nil, types.ErrMalformed } @@ -1227,7 +1263,7 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re if err != nil { return nil, err } - return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, now) + return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now) } func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) { @@ -1299,6 +1335,87 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now) } +func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid, + message, idempotencyKey string, private any, now time.Time) (bool, error) { + if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() { + return false, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return false, err + } + return tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target, + strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now) +} + +func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error { + if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantGroupLeave(tenantID, strings.TrimSpace(topic), uid, now) +} + +func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, actor, target types.Uid, + role string, modeGiven types.AccessMode, now time.Time) error { + if strings.TrimSpace(topic) == "" || actor.IsZero() || target.IsZero() || now.IsZero() { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantGroupRoleUpdate(tenantID, strings.TrimSpace(topic), actor, target, strings.TrimSpace(role), modeGiven, now) +} + +func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string, previousOwner, newOwner types.Uid, + version int, now time.Time) error { + if strings.TrimSpace(topic) == "" || previousOwner.IsZero() || newOwner.IsZero() || now.IsZero() { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantGroupOwnerTransfer(tenantID, strings.TrimSpace(topic), previousOwner, newOwner, version, now) +} + +func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) { + if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() { + return false, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return false, err + } + return tenantAdp.TenantGroupCanPost(tenantID, strings.TrimSpace(topic), uid, admin, now) +} + +func (groupBusinessMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.GroupEvent, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantGroupEventListPending(tenantID, limit, now) +} + +func (groupBusinessMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error { + if strings.TrimSpace(eventID) == "" || now.IsZero() { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantGroupEventMarkDelivered(tenantID, strings.TrimSpace(eventID), now) +} + type WorkbenchPersistenceInterface interface { Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) } @@ -1724,6 +1841,10 @@ type DevicePushTokenPersistenceInterface interface { Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) } +type DevicePushTokenQueryInterface interface { + GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) +} + type devicePushTokenMapper struct{} var DevicePushTokens DevicePushTokenPersistenceInterface @@ -1740,6 +1861,20 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token) } +func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, 0, err + } + reader, ok := tenantAdp.(interface { + TenantDevicePushTokenGetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) + }) + if !ok { + return map[types.Uid][]types.DeviceDef{}, 0, nil + } + return reader.TenantDevicePushTokenGetAll(tenantID, uids...) +} + type SafetyPersistenceInterface interface { CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) @@ -1863,7 +1998,7 @@ func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types. } func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) { - if uid.IsZero() || patch.Version <= 0 { + if uid.IsZero() || patch.Version <= 0 || !validUserPrivacyPatch(patch) { return nil, types.ErrMalformed } tenantAdp, err := tenantBusinessAdapter(tenantID) @@ -1873,6 +2008,31 @@ func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch return tenantAdp.TenantUserPrivacyUpdate(tenantID, uid, patch) } +func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool { + if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil { + return false + } + for method := range patch.AddMeMethods { + switch method { + case "account", "phone", "qr", "group": + default: + return false + } + } + validVisibility := func(value *string) bool { + if value == nil { + return true + } + switch strings.TrimSpace(*value) { + case "everyone", "friends", "nobody": + return true + default: + return false + } + } + return validVisibility(patch.ProfileVisibility) && validVisibility(patch.LastSeenVisibility) +} + 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 { return false @@ -2321,20 +2481,77 @@ func normalizeClientMsgID(head types.KVMap) (string, error) { func validateMessageHeaders(head types.KVMap) error { if schema, ok := head["x-im-schema"]; ok { - value, ok := schema.(string) - if !ok || !validIMMessageHeaderToken(value, 32) { + if !validIMMessageSchema(schema) { return types.ErrMalformed } } - if msgType, ok := head["type"]; ok { - value, ok := msgType.(string) - if !ok || !validIMMessageType(value) { + legacyType, hasLegacyType := messageHeaderString(head, "type") + productType, hasProductType := messageHeaderString(head, "x-im-type") + if raw, ok := head["type"]; ok && (!hasLegacyType || !validIMMessageType(legacyType)) { + _ = raw + return types.ErrMalformed + } + if raw, ok := head["x-im-type"]; ok && (!hasProductType || !validIMMessageType(productType)) { + _ = raw + return types.ErrMalformed + } + if hasLegacyType && hasProductType { + if canonicalIMMessageType(legacyType) != canonicalIMMessageType(productType) { return types.ErrMalformed } } return nil } +func validIMMessageSchema(value any) bool { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) == "1" + case json.Number: + parsed, err := typed.Int64() + return err == nil && parsed == 1 + case float64: + return typed == 1 + case float32: + return typed == 1 + case int: + return typed == 1 + case int8: + return typed == 1 + case int16: + return typed == 1 + case int32: + return typed == 1 + case int64: + return typed == 1 + case uint: + return typed == 1 + case uint8: + return typed == 1 + case uint16: + return typed == 1 + case uint32: + return typed == 1 + case uint64: + return typed == 1 + default: + return false + } +} + +func messageHeaderString(head types.KVMap, key string) (string, bool) { + value, ok := head[key] + if !ok { + return "", false + } + text, ok := value.(string) + if !ok { + return "", false + } + text = strings.TrimSpace(text) + return text, text != "" +} + func validIMMessageHeaderToken(value string, max int) bool { value = strings.TrimSpace(value) if value == "" || len(value) > max { @@ -2351,13 +2568,27 @@ func validIMMessageHeaderToken(value string, max int) bool { func validIMMessageType(value string) bool { switch strings.TrimSpace(value) { - case "text", "image", "file", "audio", "video", "location", "contact", "card", "call", "system": + case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card", + "card", "link", "forward_bundle", "sticker", "call", "system", "system_event": return true default: return false } } +func canonicalIMMessageType(value string) string { + switch strings.TrimSpace(value) { + case "audio": + return "voice" + case "contact": + return "contact_card" + case "system": + return "system_event" + default: + return strings.TrimSpace(value) + } +} + // DeleteList deletes multiple messages defined by a list of ranges. func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error { tenantAdp, err := tenantBusinessAdapter(tenantID) diff --git a/server/store/tenant_store_test.go b/server/store/tenant_store_test.go index 885c32d..747bab9 100644 --- a/server/store/tenant_store_test.go +++ b/server/store/tenant_store_test.go @@ -1,6 +1,7 @@ package store import ( + "encoding/json" "testing" "github.com/tinode/chat/server/store/types" @@ -11,3 +12,30 @@ func TestForTenantRejectsZero(t *testing.T) { t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err) } } + +func TestValidateMessageHeadersProductContract(t *testing.T) { + valid := []types.KVMap{ + {"x-im-schema": float64(1), "x-im-type": "text"}, + {"x-im-schema": json.Number("1"), "x-im-type": "voice"}, + {"x-im-schema": "1", "x-im-type": "contact_card"}, + {"x-im-schema": 1, "type": "audio", "x-im-type": "voice"}, + {"type": "system"}, + } + for _, head := range valid { + if err := validateMessageHeaders(head); err != nil { + t.Fatalf("validateMessageHeaders(%v) = %v, want nil", head, err) + } + } + + invalid := []types.KVMap{ + {"x-im-schema": float64(2), "x-im-type": "text"}, + {"x-im-schema": true, "x-im-type": "text"}, + {"x-im-schema": float64(1), "x-im-type": "unknown"}, + {"type": "text", "x-im-type": "image"}, + } + for _, head := range invalid { + if err := validateMessageHeaders(head); err != types.ErrMalformed { + t.Fatalf("validateMessageHeaders(%v) = %v, want malformed", head, err) + } + } +} diff --git a/server/store/types/groups.go b/server/store/types/groups.go index 3f91a0b..0351d10 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -180,3 +180,19 @@ type GroupMute struct { MutedByRaw int64 `db:"muted_by"` RevokedRaw *int64 `db:"revoked_by"` } + +type GroupEvent struct { + ID int64 `db:"id"` + EventID string `db:"event_id"` + Topic string `db:"topic"` + ActorUserID Uid `db:"-"` + EventType string `db:"event_type"` + Payload []byte `db:"payload"` + Status string `db:"status"` + Attempts int `db:"attempts"` + NextRetryAt *time.Time `db:"next_retry_at"` + DeliveredAt *time.Time `db:"delivered_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + ActorRaw int64 `db:"actor_user_id"` +} diff --git a/server/store/types/infrastructure.go b/server/store/types/infrastructure.go new file mode 100644 index 0000000..203e68f --- /dev/null +++ b/server/store/types/infrastructure.go @@ -0,0 +1,56 @@ +package types + +import ( + "crypto/sha256" + "fmt" + "time" +) + +const ( + HTTPIdempotencyProcessing = "processing" + HTTPIdempotencyCompleted = "completed" + + BusinessEventPending = "pending" + BusinessEventDelivered = "delivered" +) + +// HTTPIdempotencyRecord stores the first response for one authenticated write. +type HTTPIdempotencyRecord struct { + UserID Uid + Method string + Route string + Key string + BodyHash string + State string + ResponseStatus int + ResponseBody []byte + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +// BusinessEvent is a durable event destined for an online user or topic. +type BusinessEvent struct { + ID int64 + EventID string + TargetType string + Target string + ActorUserID Uid + EventType string + Payload []byte + Status string + Attempts int + NextRetryAt *time.Time + DeliveredAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// StableEventID derives an idempotent event identifier from domain identity. +func StableEventID(parts ...any) string { + hash := sha256.New() + for _, part := range parts { + _, _ = fmt.Fprint(hash, part, "\x00") + } + return fmt.Sprintf("evt_%x", hash.Sum(nil)[:24]) +} diff --git a/server/topic.go b/server/topic.go index ee033e4..74ba173 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1047,7 +1047,10 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, delete(head, "sender") } if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot { - if msgType, _ := head["type"].(string); msgType == "system" { + legacyType, _ := head["type"].(string) + productType, _ := head["x-im-type"].(string) + if legacyType == "system" || legacyType == "system_event" || + productType == "system" || productType == "system_event" { msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) return types.ErrPermissionDenied } @@ -1060,6 +1063,20 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, } head = normalized } + if t.cat == types.TopicCatGrp && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && + store.GroupBusiness != nil && store.Store != nil && store.Store.IsOpen() { + access := pud.modeWant & pud.modeGiven + canPost, err := store.GroupBusiness.CanPost(t.tenantID, t.name, asUid, access.IsAdmin(), msg.Timestamp) + if err != nil { + logs.Warn.Printf("topic[%s]: failed to check group mute policy: %v", t.name, err) + msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp)) + return err + } + if !canPost { + msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) + return types.ErrPermissionDenied + } + } if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && store.Relations != nil && store.Store != nil && store.Store.IsOpen() { peer := t.p2pOtherUser(asUid) @@ -2301,7 +2318,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe // because to stay in memory at least one of the users must be connected to topic. // FIXME(gene): it breaks when user A stays active in one session and connects-disconnects // from another session. The second session will not see correct LastSeen time and UserAgent. - if pud.lastSeen != nil { + if pud.lastSeen != nil && imCanViewLastSeen(t.tenantID, asUid, t.p2pOtherUser(asUid)) { desc.LastSeen = &MsgLastSeenInfo{ When: pud.lastSeen, UserAgent: pud.lastUA, @@ -2733,8 +2750,10 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level // Reporting user's subscriptions to other topics. P2P topic name is the // UID of the other user. with := sub.GetWith() + canViewLastSeen := true if with != "" { mts.Topic = with + canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with)) mts.Online = t.perSubs[with].online && !deleted && presencer } else if strings.HasPrefix(sub.Topic, "slf") { mts.Topic = "slf" @@ -2759,7 +2778,7 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level } lastSeen := sub.GetLastSeen() - if lastSeen != nil && !mts.Online { + if lastSeen != nil && !mts.Online && canViewLastSeen { mts.LastSeen = &MsgLastSeenInfo{ When: lastSeen, UserAgent: sub.GetUserAgent(), diff --git a/server/topic_test.go b/server/topic_test.go index a355276..54e6bfb 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -334,6 +334,40 @@ func TestHandleBroadcastDataP2PRequiresRelationship(t *testing.T) { } } +func TestHandleBroadcastDataGroupRejectsMutedMember(t *testing.T) { + helper := TopicTestHelper{} + helper.setUp(t, 2, types.TopicCatGrp, "grp-test", true) + defer helper.tearDown() + + oldStore := store.Store + oldGroupBusiness := store.GroupBusiness + mockStore := mock_store.NewMockPersistentStorageInterface(helper.ctrl) + mockStore.EXPECT().IsOpen().Return(true) + store.Store = mockStore + store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{Topic: "grp-test", MuteAll: true}} + memberState := helper.topic.perUser[helper.uids[1]] + memberState.modeWant = types.ModeCPublic + memberState.modeGiven = types.ModeCPublic + helper.topic.perUser[helper.uids[1]] = memberState + defer func() { + store.Store = oldStore + store.GroupBusiness = oldGroupBusiness + }() + + msg := &ClientComMessage{ + Id: "muted-1", AsUser: helper.uids[1].UserId(), Original: "grp-test", + Pub: &MsgClientPub{Topic: "grp-test", Content: "blocked"}, + sess: helper.sessions[1], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + verifyResponseCodes(helper.results[1], []int{http.StatusForbidden}, t) + if helper.topic.lastID != 0 { + t.Fatalf("muted publish changed topic sequence: %d", helper.topic.lastID) + } +} + func TestHandleBroadcastCall(t *testing.T) { numUsers := 2 helper := TopicTestHelper{} @@ -766,7 +800,7 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) { AsUser: from, Pub: &MsgClientPub{ Topic: "p2p", - Head: map[string]any{"type": "system"}, + Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"}, Content: "test", }, sess: helper.sessions[0],