feat: add im tenant db routing and chapter seven flows

This commit is contained in:
richjohnrambo
2026-08-13 11:30:48 +08:00
parent 774a448c12
commit 31f82d9c41
19 changed files with 5261 additions and 47 deletions
+111 -7
View File
@@ -1,7 +1,7 @@
# IM 第一、二章后续增强项
# IM 第一至六章后续增强项
> 日期:2026-08-12
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知
> 日期:2026-08-13
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
## 1. 第一章:登录、注册与安全
@@ -63,9 +63,113 @@
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
## 3. 统一处理建议
## 3. 第三章:单聊关系、权限与会话上下文
1. 先做 P0:统一幂等、编辑/撤回实时事件、撤回占位、验证码真实发送与限频。
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理。
3. 最后做 P2:端到端重连测试、schema 版本演进和体验型优化。
### 3.1 当前已完成基线
1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。
2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。
3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。
### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P0 | 关系变更实时同步 | 好友通过、删除、拉黑、解除拉黑后,应向双方在线端推送关系和会话能力变化。 | A 拉黑 B 后,B 端输入框无需刷新自动锁定。 |
| P0 | 关系事件 Outbox | 关系 DB 状态变化与系统通知、会话事件、Push 需要事务级衔接。 | 模拟推送失败后后台可补发,不产生重复事件。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 |
| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 非好友资料隐私裁剪 | 单聊上下文中的对端资料需要接入隐私设置、企业可见性和青少年模式。 | 非好友只能看到允许公开字段;好友看到扩展字段。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 |
## 4. 第四章:群消息、群成员与 @ 能力
### 4.1 当前已完成基线
1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。
2. 返回成员角色、公开资料、ACL 和是否可被 @。
3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。
### 4.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 |
| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 |
| P1 | 群昵称/角色变更事件 | 群昵称、角色、禁言、移除等变化需要实时推送给群成员并刷新成员列表缓存。 | 群主修改成员角色后在线端成员列表自动更新。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 |
| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 |
## 5. 第五章:通讯录、好友关系与黑名单
### 5.1 当前已完成基线
1. 好友列表支持查询、首字母、分页,返回备注、头像、显示名来源和会话 Topic。
2. 新朋友列表、好友申请、通过、拒绝链路已具备。
3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。
4. 添加好友二维码支持短期签名 Token 生成与解析。
5. 好友个人主页返回关系、资料和操作能力。
6. 群内非好友个人主页返回群角色、关系、资料和操作能力。
7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。
8. 黑名单列表和批量移出黑名单已具备。
### 5.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 好友/黑名单操作统一幂等 | 拉黑、解除、删除、批量移出、好友申请等需要统一接入 `Idempotency-Key` 存储和请求摘要。 | 客户端超时重试不会重复创建申请或重复触发事件。 |
| P0 | 好友关系实时事件 | 申请、通过、拒绝、删除、备注、拉黑等状态变化需要向相关在线端实时同步。 | 两端关系页、会话页和通讯录无需刷新自动更新。 |
| P0 | 用户搜索隐私策略 | 当前搜索基线可用,后续需要接入 `allow_user_search`、添加方式、手机号可搜索等租户和个人隐私设置。 | 禁止手机号添加的用户无法通过手机号被搜索到,且不泄露存在性。 |
| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 个人主页需按好友、非好友、群内关系、黑名单、实名/青少年等规则返回不同字段。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 |
| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 |
| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 |
| P1 | 关系审计和安全通知 | 添加、删除、拉黑、解除、二维码解析等应写审计并按策略生成系统通知。 | 用户可在通知中看到关键关系事件,后台可追溯。 |
| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 |
## 6. 第六章:群聊创建、资料维护与成员治理
### 6.1 当前已完成基线
1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。
2. 群设置、个人群设置、退出群、群昵称接口已具备。
3. 群二维码生成与扫码解析支持 `join_group` 场景。
4. 群邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。
5. 群公告最新查询、发布/更新、详情和已读接口已具备。
6. 群治理配置 `GET/PATCH /governance` 已具备。
7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 当前实现优先复用 Topic、Subscription 和 Topic Aux,把产品主链路接口跑通。
### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 群业务独立表拆分 | 当前群公告、进群申请、治理配置、禁言记录先存 Topic Aux,后续需要拆为独立表。 | 公告、申请、治理、禁言均有 tenant/topic 维度索引,可稳定分页和审计。 |
| 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 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 |
## 7. 统一处理建议
1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机。
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验和群文件索引。
3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化。
4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。
@@ -0,0 +1,316 @@
-- IM 第一至七章数据库增量审核稿
-- 仅供审核,不要直接在生产库盲执行。
-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。
-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。
-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。
SET NAMES utf8mb4;
-- hotgo 公共后台库需要先存在;如果你审核通过,可先执行:
-- CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- USE hotgo;
--
-- 租户业务库 tinode 也需要存在并完成 Tinode 基础表初始化:
-- CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- ============================================================
-- 1. 租户、认证、安全基础表
-- ============================================================
CREATE TABLE IF NOT EXISTS im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'tenant business database name, e.g. tinode_acme',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'optional full tenant DB DSN, empty means reuse base MySQL config',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0)
ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3);
CREATE TABLE IF NOT EXISTS im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
action VARCHAR(64) NOT NULL DEFAULT '',
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_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_verification_tenant_challenge(tenant_id, challenge_id),
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_security(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_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_realname_submission(tenant_id, submission_id),
INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at),
INDEX idx_im_realname_id_hash(tenant_id, id_number_hash),
CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_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_action_tokens_token(tenant_id, token_id),
INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at),
CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_youth_mode_settings(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, user_id, purpose),
CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_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_system_notifications_event(tenant_id, user_id, event_id),
INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC),
CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 2. 好友、黑名单
-- ============================================================
CREATE TABLE IF NOT EXISTS im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_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_friend_requests_request(tenant_id, request_id),
INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at),
CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE UNIQUE INDEX uk_im_friend_requests_idempotency
ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_friendships(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, user_id, peer_id),
INDEX idx_im_friendships_peer(tenant_id, peer_id, status),
CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS im_user_blocks(
tenant_id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id, blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id),
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 3. 收藏、转发、消息增强
-- ============================================================
CREATE TABLE IF NOT EXISTS im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_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_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE UNIQUE INDEX uk_im_favorites_idempotency
ON im_favorites(tenant_id, user_id, (NULLIF(idempotency_key, '')));
CREATE TABLE IF NOT EXISTS im_message_revisions(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY(id),
UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version),
CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- 4. Tinode 基础表需补齐的字段/索引
-- 如果你的库是用当前 schema.sql 初始化,这些已经存在。
-- 如果是旧库,需要逐项核对后再 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);
-- topics:租户隔离、群治理 Aux、租户维度唯一索引。
-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 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);
-- 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);
-- 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);
-- 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);
-- 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);
-- 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);
-- usertags/topictags/devices/auth/credentials 也已按租户增加 tenant_id;
-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。
+109
View File
@@ -0,0 +1,109 @@
//go:build ignore
package main
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://")
if dsn == "" {
fatal("IM_MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
fatal(err)
}
statements := []string{
"CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci",
`CREATE TABLE IF NOT EXISTS hotgo.im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
"CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci",
}
for _, stmt := range statements {
if _, err = db.Exec(stmt); err != nil {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
for _, stmt := range []string{
"ALTER TABLE hotgo.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"ALTER TABLE tinode.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name",
"ALTER TABLE tinode.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE tinode.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
} {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0)
ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant_config(tenant_id,max_users,max_groups,max_group_members,created_by,updated_by)
SELECT id,0,0,0,0,0 FROM hotgo.im_tenant WHERE code='default'
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec("UPDATE tinode.im_tenant SET db_name='tinode' WHERE code='default' AND (db_name='' OR db_name IS NULL)"); err != nil {
fatal(err)
}
var code, dbName string
var state int
if err = db.QueryRow("SELECT code, db_name, state FROM hotgo.im_tenant WHERE code='default'").Scan(&code, &dbName, &state); err != nil {
fatal(err)
}
fmt.Printf("hotgo.im_tenant default: code=%s db_name=%s state=%d\n", code, dbName, state)
var columnCount int
if err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_tenant' AND COLUMN_NAME IN ('tenant_desc','db_name','db_dsn')`).Scan(&columnCount); err != nil {
fatal(err)
}
fmt.Printf("tinode.im_tenant routing columns: %d/3\n", columnCount)
}
func fatal(v any) {
fmt.Fprintln(os.Stderr, v)
os.Exit(1)
}
+16
View File
@@ -215,6 +215,12 @@ type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, 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 {
@@ -281,6 +287,12 @@ type TenantBusinessAdapter interface {
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error)
TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error)
TenantFavoriteGet(t.TenantID, t.Uid, string) (*t.FavoriteItem, error)
TenantFavoriteDelete(t.TenantID, t.Uid, string) error
TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
@@ -291,6 +303,7 @@ type TenantBusinessAdapter interface {
TenantFileGet(t.TenantID, string) (*t.FileDef, error)
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error
TenantFileUserHasAccess(t.TenantID, string, t.Uid) (bool, error)
TenantPCacheGet(t.TenantID, string) (string, error)
TenantPCacheUpsert(t.TenantID, string, string, bool) error
@@ -326,8 +339,11 @@ type TenantBusinessAdapter interface {
TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error
TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error)
TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, error)
TenantFriendshipUpdateRemark(t.TenantID, t.Uid, t.Uid, string, time.Time) (*t.Friendship, error)
TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error
TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, error)
TenantUserBlockList(t.TenantID, t.Uid, string, int) ([]t.BlockListItem, string, error)
TenantUserBlockUpsert(t.TenantID, t.Uid, t.Uid, time.Time) (*t.UserBlock, error)
TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error
TenantUserSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.User, string, error)
}
+261 -24
View File
@@ -13,11 +13,13 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
ms "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/auth"
dbadapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
@@ -25,9 +27,10 @@ import (
// adapter holds MySQL connection data.
type adapter struct {
db *sqlx.DB
dsn string
dbName string
db *sqlx.DB
registryDB *sqlx.DB
dsn string
dbName string
// Maximum number of records to return
maxResults int
// Maximum number of message records to return
@@ -38,6 +41,12 @@ type adapter struct {
sqlTimeout time.Duration
// DB transaction timeout.
txTimeout time.Duration
baseConfig *configType
tenantDBRouting bool
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantMu sync.Mutex
}
const (
@@ -78,6 +87,11 @@ type configType struct {
// DB request timeout (in seconds).
// If 0 (or negative), no timeout is applied.
SqlTimeout int `json:"sql_timeout,omitempty"`
// TenantRegistryDB is the public backend database, e.g. hotgo.
TenantRegistryDB string `json:"tenant_registry_db,omitempty"`
// TenantDBRouting enables "one tenant, one database" routing.
TenantDBRouting bool `json:"tenant_db_routing,omitempty"`
}
func (a *adapter) getContext() (context.Context, context.CancelFunc) {
@@ -94,6 +108,44 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
return context.Background(), nil
}
func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) {
if strings.TrimSpace(dsn) != "" {
cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://"))
if err != nil {
return nil, "", err
}
if dbName != "" {
cfg.DBName = dbName
}
dsn = cfg.FormatDSN()
dbName = cfg.DBName
} else {
cfg := config.Config
if dbName != "" {
cfg.DBName = dbName
}
if cfg.DBName == "" {
cfg.DBName = defaultDatabase
}
dsn = cfg.FormatDSN()
dbName = cfg.DBName
}
db, err := sqlx.Open("mysql", dsn)
if err != nil {
return nil, "", err
}
if config.MaxOpenConns > 0 {
db.SetMaxOpenConns(config.MaxOpenConns)
}
if config.MaxIdleConns > 0 {
db.SetMaxIdleConns(config.MaxIdleConns)
}
if config.ConnMaxLifetime > 0 {
db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
}
return db, dbName, nil
}
// Open initializes database session
func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.db != nil {
@@ -139,6 +191,11 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.dbName == "" {
a.dbName = defaultDatabase
}
config.Config.DBName = a.dbName
a.baseConfig = &config
a.tenantDBRouting = config.TenantDBRouting
a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB)
a.tenantAdapters = make(map[t.TenantID]*adapter)
if a.maxResults <= 0 {
a.maxResults = defaultMaxResults
@@ -149,7 +206,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
}
// This just initializes the driver but does not open the network connection.
a.db, err = sqlx.Open("mysql", a.dsn)
a.db, a.dbName, err = openMySQLDB(config, a.dsn, a.dbName)
if err != nil {
return err
}
@@ -162,20 +219,20 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
err = nil
}
if err == nil {
if config.MaxOpenConns > 0 {
a.db.SetMaxOpenConns(config.MaxOpenConns)
}
if config.MaxIdleConns > 0 {
a.db.SetMaxIdleConns(config.MaxIdleConns)
}
if config.ConnMaxLifetime > 0 {
a.db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
}
if config.SqlTimeout > 0 {
a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second
// We allocate txTimeoutMultiplier times sqlTimeout for transactions.
a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second
}
if a.tenantRegistryDB != "" && a.tenantRegistryDB != a.dbName {
a.registryDB, _, err = openMySQLDB(config, a.dsn, a.tenantRegistryDB)
if err == nil {
err = a.registryDB.Ping()
}
if err != nil {
return errors.New("mysql adapter failed to open tenant registry database: " + err.Error())
}
}
}
return err
}
@@ -183,8 +240,26 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
// Close closes the underlying database connection
func (a *adapter) Close() error {
var err error
a.tenantMu.Lock()
for tenantID, tenantAdp := range a.tenantAdapters {
if tenantAdp != nil && tenantAdp.db != nil {
if closeErr := tenantAdp.db.Close(); err == nil {
err = closeErr
}
}
delete(a.tenantAdapters, tenantID)
}
a.tenantMu.Unlock()
if a.registryDB != nil {
if closeErr := a.registryDB.Close(); err == nil {
err = closeErr
}
a.registryDB = nil
}
if a.db != nil {
err = a.db.Close()
if closeErr := a.db.Close(); err == nil {
err = closeErr
}
a.db = nil
a.version = -1
}
@@ -285,18 +360,112 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) {
}
var tenant t.Tenant
err := a.db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
db := a.db
if a.registryDB != nil {
db = a.registryDB
}
err := db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE code=? LIMIT 1`, code)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if isUnknownColumn(err) {
err = db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE code=? LIMIT 1`, code)
}
if err != nil {
return nil, err
}
return &tenant, nil
}
func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) {
if !a.tenantDBRouting || tenantID == defaultTenantID {
return a, a, nil
}
a.tenantMu.Lock()
if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil {
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
}
a.tenantMu.Unlock()
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
dbName := strings.TrimSpace(tenant.DBName)
dsn := strings.TrimSpace(tenant.DBDSN)
if dbName == "" && dsn == "" {
return a, a, nil
}
if a.baseConfig == nil {
return nil, nil, t.ErrFailed
}
db, resolvedDBName, err := openMySQLDB(*a.baseConfig, mysqlDefaultString(dsn, a.dsn), dbName)
if err != nil {
return nil, nil, err
}
if err = db.Ping(); err != nil {
db.Close()
return nil, nil, err
}
tenantAdp := &adapter{
db: db,
dsn: mysqlDefaultString(dsn, a.dsn),
dbName: resolvedDBName,
maxResults: a.maxResults,
maxMessageResults: a.maxMessageResults,
version: a.version,
sqlTimeout: a.sqlTimeout,
txTimeout: a.txTimeout,
baseConfig: a.baseConfig,
}
a.tenantMu.Lock()
if existing := a.tenantAdapters[tenantID]; existing != nil {
a.tenantMu.Unlock()
db.Close()
return existing, existing, nil
}
a.tenantAdapters[tenantID] = tenantAdp
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
}
func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
db := a.db
if a.registryDB != nil {
db = a.registryDB
}
var tenant t.Tenant
err := db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if isUnknownColumn(err) {
err = db.GetContext(ctx, &tenant,
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
}
if err != nil {
return nil, err
}
if !tenant.IsActive() {
return nil, t.ErrPermissionDenied
}
return &tenant, nil
}
// CreateDb initializes the storage.
func (a *adapter) CreateDb(reset bool) error {
var err error
@@ -348,6 +517,8 @@ func (a *adapter) CreateDb(reset bool) error {
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
@@ -407,8 +578,8 @@ func (a *adapter) CreateDb(reset bool) error {
}
if _, err = tx.Exec(
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
return err
}
@@ -997,10 +1168,12 @@ func (a *adapter) UpgradeDb() error {
if _, err := a.db.Exec(
`CREATE TABLE im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
state SMALLINT NOT NULL,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -1030,8 +1203,8 @@ func (a *adapter) UpgradeDb() error {
}
if _, err := a.db.Exec(
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
return err
}
@@ -1162,6 +1335,10 @@ func (a *adapter) UpgradeDb() error {
}
}
if err := ensureTenantRoutingColumns(a); err != nil {
return err
}
if a.version != adpVersion {
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
". DB is still at " + strconv.Itoa(a.version))
@@ -1191,6 +1368,20 @@ func createIMNotificationTables(tx *sql.Tx) error {
return err
}
func ensureTenantRoutingColumns(a *adapter) error {
statements := []string{
"ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"UPDATE im_tenant SET db_name='tinode' WHERE code='default' AND db_name=''",
}
for _, stmt := range statements {
if _, err := a.db.Exec(stmt); err != nil && !isDuplicateColumn(err) {
return err
}
}
return nil
}
func upgradeIMMessageActions(a *adapter) error {
statements := []string{
"ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
@@ -1272,6 +1463,29 @@ func createIMRelationTables(exec sqlExecutor) error {
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id),
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`,
`CREATE TABLE im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_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_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`,
}
for _, stmt := range statements {
if _, err := exec.Exec(stmt); err != nil {
@@ -4608,6 +4822,29 @@ func isMissingDb(err error) bool {
return ok && myerr.Number == 1049
}
func isUnknownColumn(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1054
}
func isDuplicateColumn(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1060
}
func mysqlDefaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
// GetTestAdapter returns an adapter object. Useful for running tests.
func GetTestAdapter() *adapter {
return &adapter{}
+29 -2
View File
@@ -19,6 +19,8 @@ CREATE TABLE im_tenant(
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
@@ -72,8 +74,8 @@ CREATE TABLE im_verification_codes(
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,1,0,0);
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0);
CREATE TABLE kvmeta(
@@ -246,6 +248,31 @@ CREATE TABLE im_user_blocks(
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
CREATE TABLE im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_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_favorites_favorite(tenant_id, favorite_id),
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key),
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
# Indexed user tags.
CREATE TABLE usertags(
id INT NOT NULL AUTO_INCREMENT,
+426
View File
@@ -1549,6 +1549,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
return msgs, rows.Err()
}
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"m.tenant_id=?", "m.topic=?", "m.delid=0"}
args := []any{id, topic}
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "m.seqid<?")
args = append(args, parsed)
}
queryText = strings.TrimSpace(queryText)
if queryText != "" {
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
if messageType != "" && messageType != "all" {
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.x-im-type'))=?)")
args = append(args, messageType, messageType)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
queryArgs := append([]any{decodeUID(uid)}, args...)
queryArgs = append(queryArgs, limit+1)
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 "+
"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 {
return nil, "", err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
nextCursor := ""
for rows.Next() {
var msg t.Message
if err = rows.StructScan(&msg); err != nil {
return nil, "", err
}
msg.From = common.EncodeUidString(msg.From).String()
msg.Head = kvMapFromJSON(msg.Head)
msg.Content = common.FromJSON(msg.Content)
if len(msgs) == limit {
nextCursor = strconv.Itoa(msg.SeqId)
continue
}
msgs = append(msgs, msg)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return msgs, nextCursor, nil
}
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) {
row := struct {
t.Message
@@ -1577,6 +1640,146 @@ func messageVersion(version int) int {
return version
}
func kvMapFromJSON(value any) t.KVMap {
switch parsed := common.FromJSON(value).(type) {
case map[string]any:
return t.KVMap(parsed)
case t.KVMap:
return parsed
default:
return nil
}
}
func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
if err := validTenant(id); err != nil || fav == nil || fav.TenantID != id || fav.UserID.IsZero() || fav.FavoriteID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_favorites(tenant_id,favorite_id,user_id,item_type,source_topic,source_seq,snapshot,attachment_refs,availability,idempotency_key,collected_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
id, fav.FavoriteID, decodeUID(fav.UserID), fav.ItemType, fav.SourceTopic, fav.SourceSeq, common.ToJSON(fav.Snapshot), common.ToJSON(fav.AttachmentRefs), fav.Availability, fav.IdempotencyKey, fav.CollectedAt, fav.CreatedAt, fav.UpdatedAt)
if err != nil {
return nil, err
}
return a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
}
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"tenant_id=?", "user_id=?"}
args := []any{id, decodeUID(uid)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "id<?")
args = append(args, parsed)
}
if itemType != "" && itemType != "all" {
where = append(where, "item_type=?")
args = append(args, itemType)
}
if queryText = strings.TrimSpace(queryText); queryText != "" {
where = append(where, "CAST(snapshot AS CHAR) LIKE ?")
args = append(args, "%"+queryText+"%")
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE "+strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FavoriteItem, 0, limit)
nextCursor := ""
for rows.Next() {
item, err := scanFavorite(rows)
if err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(item.ID, 10)
continue
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) (*t.FavoriteItem, error) {
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, t.ErrNotFound
}
item, err := scanFavorite(rows)
if err != nil {
return nil, err
}
return &item, nil
}
func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID string) error {
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "DELETE FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID)
if err != nil {
return err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return t.ErrNotFound
}
return nil
}
func scanFavorite(rows *sqlx.Rows) (t.FavoriteItem, error) {
row := struct {
t.FavoriteItem
UserID int64 `db:"user_id"`
}{}
if err := rows.StructScan(&row); err != nil {
return t.FavoriteItem{}, err
}
item := row.FavoriteItem
item.UserID = store.EncodeUid(row.UserID)
item.Snapshot = common.FromJSON(item.Snapshot)
item.AttachmentRefs = common.FromJSON(item.AttachmentRefs)
return item, nil
}
func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
@@ -1954,6 +2157,57 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID,
return tx.Commit()
}
func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) (bool, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return false, t.ErrMalformed
}
fileID := t.ParseUid(fid)
if fileID.IsZero() {
return false, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var allowed int
err := a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM fileuploads AS fu
WHERE fu.tenant_id=? AND fu.id=? AND fu.status=? AND (
fu.userid=?
OR EXISTS (
SELECT 1 FROM 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
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
ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid
INNER JOIN 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
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)
)
)
) LIMIT 1`,
id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid))
if err != nil {
return false, err
}
return allowed > 0, nil
}
func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) {
if err := validTenant(id); err != nil {
return "", err
@@ -2998,6 +3252,26 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F
}, nil
}
func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) {
if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() || len([]rune(remark)) > 64 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
"UPDATE im_friendships SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?",
remark, now, id, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend)
if err != nil {
return nil, err
}
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
return nil, t.ErrNotFound
}
return a.TenantFriendshipGet(id, userID, peerID)
}
func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, now time.Time) error {
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return t.ErrMalformed
@@ -3044,6 +3318,79 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid)
}, nil
}
func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) {
if err := validTenant(id); err != nil || blockerID.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
where := []string{"b.tenant_id=?", "b.blocker_id=?"}
args := []any{id, decodeUID(blockerID)}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "b.blocked_id<?")
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
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.tenant_id=b.tenant_id AND u.id=b.blocked_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY b.blocked_id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.BlockListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
TenantID t.TenantID `db:"tenant_id"`
BlockerID int64 `db:"blocker_id"`
BlockedID int64 `db:"blocked_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
Public any
Trusted any
}{}
if err = rows.StructScan(&row); err != nil {
return nil, "", err
}
if len(items) == limit {
nextCursor = strconv.FormatInt(row.BlockedID, 10)
continue
}
blockedID := store.EncodeUid(row.BlockedID)
items = append(items, t.BlockListItem{
Block: t.UserBlock{
TenantID: row.TenantID,
BlockerID: store.EncodeUid(row.BlockerID),
BlockedID: blockedID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
},
User: &t.User{
ObjHeader: t.ObjHeader{Id: blockedID.String()},
TenantID: row.TenantID,
Public: common.FromJSON(row.Public),
Trusted: common.FromJSON(row.Trusted),
},
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Uid, now time.Time) (*t.UserBlock, error) {
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
return nil, t.ErrMalformed
@@ -3075,6 +3422,85 @@ func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Ui
return err
}
func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searchType, cursor string, limit int) ([]t.User, string, error) {
if err := validTenant(id); err != nil || viewer.IsZero() {
return nil, "", t.ErrMalformed
}
queryText = strings.TrimSpace(queryText)
if queryText == "" {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 50 {
limit = 20
}
where := []string{"u.tenant_id=?", "u.id<>?", "u.state=?"}
args := []any{id, decodeUID(viewer), t.StateOK}
if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 {
return nil, "", t.ErrMalformed
}
where = append(where, "u.id>?")
args = append(args, parsed)
}
join := ""
switch searchType {
case "", "account":
like := "%" + queryText + "%"
join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND 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.tenant_id=u.tenant_id AND ut.userid=u.id"
where = append(where, "(ut.tag=? OR u.id=?)")
args = append(args, queryText, userIDNumericArg(queryText))
case "phone":
where = append(where, `EXISTS (
SELECT 1 FROM credentials AS c
WHERE c.tenant_id=u.tenant_id AND c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true
)`)
args = append(args, queryText)
default:
return nil, "", t.ErrMalformed
}
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+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
users := make([]t.User, 0, limit)
nextCursor := ""
for rows.Next() {
var user t.User
if err = rows.StructScan(&user); err != nil {
return nil, "", err
}
scanTenantUser(&user)
if len(users) == limit {
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
continue
}
users = append(users, user)
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return users, nextCursor, nil
}
func userIDNumericArg(value string) int64 {
if uid := t.ParseUserId(value); !uid.IsZero() {
return decodeUID(uid)
}
return 0
}
func (a *adapter) friendRequestByRequestID(ctx context.Context, tenantID t.TenantID, requestID string) (*t.FriendRequest, error) {
return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID)
}
+55 -7
View File
@@ -18,6 +18,7 @@ import (
"mime"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
@@ -131,12 +132,6 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
return
}
if req.Method == http.MethodHead {
wrt.WriteHeader(http.StatusOK)
logs.Info.Println("media serve: completed", req.Method, "uid=", uid)
return
}
fd, rsc, err := mh.Download(tenantID, req.URL.String())
if err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
@@ -145,7 +140,15 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
defer rsc.Close()
if err = authorizeLargeFileDownload(tenantID, uid, req.URL.String(), fd); err != nil {
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
return
}
wrt.Header().Set("Content-Type", fd.MimeType)
if fd.ETag != "" {
wrt.Header().Set("ETag", strconv.Quote(fd.ETag))
}
asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt"))
// Force download for html files as a security measure.
asAttachment = asAttachment ||
@@ -159,7 +162,7 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
strings.HasPrefix(fd.MimeType, "multipart/") ||
strings.HasPrefix(fd.MimeType, "text/")
if asAttachment {
wrt.Header().Set("Content-Disposition", "attachment")
wrt.Header().Set("Content-Disposition", contentDispositionAttachment(fd))
}
http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc)
@@ -415,6 +418,11 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
defer rsc.Close()
if err = authorizeLargeFileDownload(tenantID, uid, req.GetUri(), fd); err != nil {
writeResponse(decodeStoreError(err, msgID, now, nil), err)
return nil
}
resp.Code = http.StatusOK
resp.Text = http.StatusText(http.StatusOK)
resp.Meta.Name = fd.Location
@@ -445,6 +453,46 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
return err
}
func authorizeLargeFileDownload(tenantID types.TenantID, uid types.Uid, rawURL string, fd *types.FileDef) error {
if fd == nil {
return types.ErrNotFound
}
fid := fd.Uid()
if fid.IsZero() && rawURL != "" && store.Store != nil {
fid = store.Store.GetMediaHandler().GetIdFromUrl(rawURL)
}
if fid.IsZero() || fd.TenantID != tenantID || fd.Status != types.UploadCompleted {
return types.ErrNotFound
}
allowed, err := store.Files.UserHasAccess(tenantID, fid.String(), uid)
if err != nil {
return err
}
if !allowed {
return types.ErrPermissionDenied
}
return nil
}
func contentDispositionAttachment(fd *types.FileDef) string {
name := strings.TrimSpace(filepath.Base(fd.Location))
if name == "." || name == "/" || name == string(filepath.Separator) {
name = fd.Uid().String()
}
clean := strings.Map(func(r rune) rune {
switch {
case r < 32 || r == 127 || r == '"' || r == '\\' || r == '/' || r == ':':
return -1
default:
return r
}
}, name)
if clean == "" {
clean = fd.Uid().String()
}
return mime.FormatMediaType("attachment", map[string]string{"filename": clean})
}
// LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP.
func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {
now := types.TimeNow()
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
func TestAuthorizeLargeFileDownloadDenied(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldFiles := store.Files
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
defer func() { store.Files = oldFiles }()
fd := &types.FileDef{
ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
TenantID: 7,
Status: types.UploadCompleted,
MimeType: "text/plain",
Location: "notes.txt",
}
files.EXPECT().UserHasAccess(types.TenantID(7), types.Uid(100).String(), types.Uid(42)).Return(false, nil)
if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrPermissionDenied {
t.Fatalf("expected permission denied, got %v", err)
}
}
func TestContentDispositionAttachmentSanitizesName(t *testing.T) {
fd := &types.FileDef{
ObjHeader: types.ObjHeader{Id: types.Uid(100).String()},
Location: `../bad"name.txt`,
}
got := contentDispositionAttachment(fd)
if got != `attachment; filename=badname.txt` {
t.Fatalf("unexpected content disposition: %s", got)
}
}
+2652 -5
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -782,6 +782,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
mux.HandleFunc(config.ApiPath+"v1/im/security/urls/check", serveIMSecurityURLCheck)
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges)
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName)
@@ -792,11 +793,19 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource)
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups)
mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource)
mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes)
mux.HandleFunc(config.ApiPath+"v1/im/qrcodes/resolve", serveIMQRCodesResolve)
mux.HandleFunc(config.ApiPath+"v1/im/users/search", serveIMUserSearch)
mux.HandleFunc(config.ApiPath+"v1/im/users/", serveIMUserResource)
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests)
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction)
mux.HandleFunc(config.ApiPath+"v1/im/blocks", serveIMBlocks)
mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser)
mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends)
mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource)
+31
View File
@@ -1144,6 +1144,22 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, top
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt)
}
// Search mocks base method.
func (m *MockMessagesPersistenceInterface) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Search", tenantID, topic, forUser, query, messageType, cursor, limit)
ret0, _ := ret[0].([]types.Message)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// Search indicates an expected call of Search.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic, forUser, query, messageType, cursor, limit interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Search), tenantID, topic, forUser, query, messageType, cursor, limit)
}
// GetOne mocks base method.
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
m.ctrl.T.Helper()
@@ -1342,6 +1358,21 @@ func (mr *MockFilePersistenceInterfaceMockRecorder) LinkAttachments(tenantID, to
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkAttachments", reflect.TypeOf((*MockFilePersistenceInterface)(nil).LinkAttachments), tenantID, topic, msgId, attachments)
}
// UserHasAccess mocks base method.
func (m *MockFilePersistenceInterface) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UserHasAccess", tenantID, fid, uid)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UserHasAccess indicates an expected call of UserHasAccess.
func (mr *MockFilePersistenceInterfaceMockRecorder) UserHasAccess(tenantID, fid, uid interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserHasAccess", reflect.TypeOf((*MockFilePersistenceInterface)(nil).UserHasAccess), tenantID, fid, uid)
}
// StartUpload mocks base method.
func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
m.ctrl.T.Helper()
+184
View File
@@ -149,6 +149,13 @@ func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, erro
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
if routed, ok := adp.(adapter.TenantRoutedAdapter); ok {
_, authAdp, err := routed.TenantAdapterFor(tenantID)
if err != nil {
return nil, err
}
return authAdp, nil
}
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
if !ok {
return nil, types.ErrUnsupported
@@ -160,6 +167,17 @@ func tenantBusinessAdapter(tenantID types.TenantID) (adapter.TenantBusinessAdapt
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
if routed, ok := adp.(adapter.TenantRoutedAdapter); ok {
businessAdp, _, err := routed.TenantAdapterFor(tenantID)
if err != nil {
return nil, err
}
if !businessAdp.TenantBusinessReady() {
return nil, types.ErrUnsupported
}
activeTenantIDs.Store(tenantID, struct{}{})
return businessAdp, nil
}
tenantAdp, ok := adp.(adapter.TenantBusinessAdapter)
if !ok {
return nil, types.ErrUnsupported
@@ -960,6 +978,7 @@ type MessagesPersistenceInterface interface {
DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error
GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error)
GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)
Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error)
}
// messagesMapper is a concrete type implementing MessagesPersistenceInterface.
@@ -1003,14 +1022,75 @@ type RelationsPersistenceInterface interface {
GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error)
CanSendP2P(tenantID types.TenantID, userID, peerID types.Uid) (bool, string, error)
BlockUser(tenantID types.TenantID, blockerID, blockedID types.Uid, now time.Time) (*types.UserBlock, error)
ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error)
UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error
DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error
UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error)
SearchUsers(tenantID types.TenantID, viewer types.Uid, query, searchType, cursor string, limit int) ([]types.UserSearchItem, string, error)
}
type relationsMapper struct{}
var Relations RelationsPersistenceInterface
type FavoritesPersistenceInterface interface {
Create(tenantID types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error)
List(tenantID types.TenantID, uid types.Uid, itemType, query, cursor string, limit int) ([]types.FavoriteItem, string, error)
Get(tenantID types.TenantID, uid types.Uid, favoriteID string) (*types.FavoriteItem, error)
Delete(tenantID types.TenantID, uid types.Uid, favoriteID string) error
}
type favoritesMapper struct{}
var Favorites FavoritesPersistenceInterface
func (favoritesMapper) Create(tenantID types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) {
if item == nil || item.TenantID != tenantID || item.UserID.IsZero() || item.FavoriteID == "" {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantFavoriteCreate(tenantID, item)
}
func (favoritesMapper) List(tenantID types.TenantID, uid types.Uid, itemType, query, cursor string, limit int) ([]types.FavoriteItem, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantFavoriteList(tenantID, uid, itemType, query, cursor, limit)
}
func (favoritesMapper) Get(tenantID types.TenantID, uid types.Uid, favoriteID string) (*types.FavoriteItem, error) {
if uid.IsZero() || strings.TrimSpace(favoriteID) == "" {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantFavoriteGet(tenantID, uid, favoriteID)
}
func (favoritesMapper) Delete(tenantID types.TenantID, uid types.Uid, favoriteID string) error {
if uid.IsZero() || strings.TrimSpace(favoriteID) == "" {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantFavoriteDelete(tenantID, uid, favoriteID)
}
func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) {
if req == nil || req.TenantID != tenantID || req.ApplicantID.IsZero() || req.TargetID.IsZero() || req.ApplicantID == req.TargetID {
return nil, types.ErrMalformed
@@ -1111,6 +1191,20 @@ func (relationsMapper) BlockUser(tenantID types.TenantID, blockerID, blockedID t
return tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now)
}
func (relationsMapper) ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error) {
if blockerID.IsZero() {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantUserBlockList(tenantID, blockerID, cursor, limit)
}
func (relationsMapper) UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error {
if blockerID.IsZero() || blockedID.IsZero() || blockerID == blockedID {
return types.ErrMalformed
@@ -1133,6 +1227,68 @@ func (relationsMapper) DeleteFriend(tenantID types.TenantID, userID, peerID type
return tenantAdp.TenantFriendshipDeletePair(tenantID, userID, peerID, now)
}
func (relationsMapper) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) {
if userID.IsZero() || peerID.IsZero() || userID == peerID || len([]rune(remark)) > 64 {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantFriendshipUpdateRemark(tenantID, userID, peerID, remark, now)
}
func (relationsMapper) SearchUsers(tenantID types.TenantID, viewer types.Uid, query, searchType, cursor string, limit int) ([]types.UserSearchItem, string, error) {
if viewer.IsZero() || strings.TrimSpace(query) == "" {
return nil, "", types.ErrMalformed
}
switch searchType {
case "", "account", "exact_account", "phone":
default:
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 50 {
limit = 20
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
users, nextCursor, err := tenantAdp.TenantUserSearch(tenantID, viewer, query, searchType, cursor, limit)
if err != nil {
return nil, "", err
}
items := make([]types.UserSearchItem, 0, len(users))
for i := range users {
uid := users[i].Uid()
ctx, err := relationshipContext(tenantAdp, tenantID, viewer, uid, viewer.P2PName(uid))
if err != nil {
return nil, "", err
}
items = append(items, types.UserSearchItem{
User: &users[i],
Relationship: ctx.Relationship,
RequestID: ctx.RequestID,
CanAdd: ctx.Relationship == types.RelationshipNone,
Action: relationshipAction(ctx.Relationship),
})
}
return items, nextCursor, nil
}
func relationshipAction(relationship string) string {
switch relationship {
case types.RelationshipFriend:
return "chat"
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
return "pending"
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
return "none"
default:
return "add"
}
}
func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) {
ctx := &types.RelationshipContext{
TenantID: tenantID,
@@ -1381,6 +1537,20 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type
return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
}
func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
if topic == "" || forUser.IsZero() {
return nil, "", types.ErrMalformed
}
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, "", err
}
return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit)
}
// GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list.
func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1649,6 +1819,8 @@ type FilePersistenceInterface interface {
// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
// from being garbage collected.
LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error
// UserHasAccess checks if user can download a file through an active upload, user, topic, or message reference.
UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error)
}
// fileMapper is concrete type which implements FilePersistenceInterface.
@@ -1738,6 +1910,17 @@ func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId t
return nil
}
func (fileMapper) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) {
if fid == "" || uid.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantFileUserHasAccess(tenantID, fid, uid)
}
// PersistentCacheInterface is an interface which defines methods used for accessing persistent key-value cache.
type PersistentCacheInterface interface {
// Get reads a persistent cache entry.
@@ -2004,4 +2187,5 @@ func init() {
Verifications = verificationMapper{}
Security = userSecurityMapper{}
Relations = relationsMapper{}
Favorites = favoritesMapper{}
}
+20
View File
@@ -0,0 +1,20 @@
package types
import "time"
type FavoriteItem struct {
ID int64
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
FavoriteID string `db:"favorite_id"`
UserID Uid `db:"user_id"`
ItemType string `db:"item_type"`
SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"`
Snapshot any
AttachmentRefs any `db:"attachment_refs"`
Availability string `db:"availability"`
IdempotencyKey string `db:"idempotency_key"`
CollectedAt time.Time `db:"collected_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
+13
View File
@@ -74,3 +74,16 @@ type FriendRequestListItem struct {
Peer *User
Direction string
}
type UserSearchItem struct {
User *User
Relationship string
RequestID string
CanAdd bool
Action string
}
type BlockListItem struct {
Block UserBlock
User *User
}
+2
View File
@@ -35,6 +35,8 @@ type Tenant struct {
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"`
DBName string `db:"db_name" json:"-"`
DBDSN string `db:"db_dsn" json:"-"`
State TenantState `db:"state" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
CreatedBy int64 `db:"created_by" json:"-"`
+5 -1
View File
@@ -290,7 +290,11 @@
"mysql": {
// MySQL connection settings via DSN.
// See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax.
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
"dsn": "mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
// Public tenant registry database. Tenant business data remains in tinode or per-tenant DBs.
"tenant_registry_db": "hotgo",
// Route tenant-scoped auth/business queries to each tenant's DB according to hotgo.im_tenant.
"tenant_db_routing": true,
// MySQL connection pool settings.
// Maximum number of open connections to the database. Default: 0 (unlimited).
"max_open_conns": 64,
+3 -1
View File
@@ -10,7 +10,9 @@
},
"mysql": {
"database": "tinode",
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_unicode_ci"
"dsn": "mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_unicode_ci",
"tenant_registry_db": "hotgo",
"tenant_db_routing": true
},
"rethinkdb": {
"database": "tinode",