feat: add im tenant db routing and chapter seven flows
This commit is contained in:
+111
-7
@@ -1,7 +1,7 @@
|
|||||||
# IM 第一、二章后续增强项
|
# IM 第一至六章后续增强项
|
||||||
|
|
||||||
> 日期:2026-08-12
|
> 日期:2026-08-13
|
||||||
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知
|
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理
|
||||||
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
|
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
|
||||||
|
|
||||||
## 1. 第一章:登录、注册与安全
|
## 1. 第一章:登录、注册与安全
|
||||||
@@ -63,9 +63,113 @@
|
|||||||
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
|
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
|
||||||
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
|
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
|
||||||
|
|
||||||
## 3. 统一处理建议
|
## 3. 第三章:单聊关系、权限与会话上下文
|
||||||
|
|
||||||
1. 先做 P0:统一幂等、编辑/撤回实时事件、撤回占位、验证码真实发送与限频。
|
### 3.1 当前已完成基线
|
||||||
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理。
|
|
||||||
3. 最后做 P2:端到端重连测试、schema 版本演进和体验型优化。
|
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` 中对应章节状态。
|
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 逐项补齐。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -215,6 +215,12 @@ type TenantAdapter interface {
|
|||||||
TenantGetByCode(code string) (*t.Tenant, error)
|
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.
|
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
|
||||||
// It is separate from Adapter while the remaining persistence domains are migrated.
|
// It is separate from Adapter while the remaining persistence domains are migrated.
|
||||||
type TenantAuthAdapter interface {
|
type TenantAuthAdapter interface {
|
||||||
@@ -281,6 +287,12 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
|
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
|
||||||
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
|
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
|
||||||
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]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
|
TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
|
||||||
TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, 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)
|
TenantFileGet(t.TenantID, string) (*t.FileDef, error)
|
||||||
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
|
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
|
||||||
TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []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)
|
TenantPCacheGet(t.TenantID, string) (string, error)
|
||||||
TenantPCacheUpsert(t.TenantID, string, string, bool) 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
|
TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error
|
||||||
TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error)
|
TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error)
|
||||||
TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, 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
|
TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error
|
||||||
TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, 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)
|
TenantUserBlockUpsert(t.TenantID, t.Uid, t.Uid, time.Time) (*t.UserBlock, error)
|
||||||
TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error
|
TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error
|
||||||
|
TenantUserSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.User, string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
+261
-24
@@ -13,11 +13,13 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ms "github.com/go-sql-driver/mysql"
|
ms "github.com/go-sql-driver/mysql"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/tinode/chat/server/auth"
|
"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/db/common"
|
||||||
"github.com/tinode/chat/server/store"
|
"github.com/tinode/chat/server/store"
|
||||||
t "github.com/tinode/chat/server/store/types"
|
t "github.com/tinode/chat/server/store/types"
|
||||||
@@ -25,9 +27,10 @@ import (
|
|||||||
|
|
||||||
// adapter holds MySQL connection data.
|
// adapter holds MySQL connection data.
|
||||||
type adapter struct {
|
type adapter struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
dsn string
|
registryDB *sqlx.DB
|
||||||
dbName string
|
dsn string
|
||||||
|
dbName string
|
||||||
// Maximum number of records to return
|
// Maximum number of records to return
|
||||||
maxResults int
|
maxResults int
|
||||||
// Maximum number of message records to return
|
// Maximum number of message records to return
|
||||||
@@ -38,6 +41,12 @@ type adapter struct {
|
|||||||
sqlTimeout time.Duration
|
sqlTimeout time.Duration
|
||||||
// DB transaction timeout.
|
// DB transaction timeout.
|
||||||
txTimeout time.Duration
|
txTimeout time.Duration
|
||||||
|
|
||||||
|
baseConfig *configType
|
||||||
|
tenantDBRouting bool
|
||||||
|
tenantRegistryDB string
|
||||||
|
tenantAdapters map[t.TenantID]*adapter
|
||||||
|
tenantMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -78,6 +87,11 @@ type configType struct {
|
|||||||
// DB request timeout (in seconds).
|
// DB request timeout (in seconds).
|
||||||
// If 0 (or negative), no timeout is applied.
|
// If 0 (or negative), no timeout is applied.
|
||||||
SqlTimeout int `json:"sql_timeout,omitempty"`
|
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) {
|
func (a *adapter) getContext() (context.Context, context.CancelFunc) {
|
||||||
@@ -94,6 +108,44 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
|
|||||||
return context.Background(), nil
|
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
|
// Open initializes database session
|
||||||
func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||||
if a.db != nil {
|
if a.db != nil {
|
||||||
@@ -139,6 +191,11 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
|||||||
if a.dbName == "" {
|
if a.dbName == "" {
|
||||||
a.dbName = defaultDatabase
|
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 {
|
if a.maxResults <= 0 {
|
||||||
a.maxResults = defaultMaxResults
|
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.
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -162,20 +219,20 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
|||||||
err = nil
|
err = nil
|
||||||
}
|
}
|
||||||
if 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 {
|
if config.SqlTimeout > 0 {
|
||||||
a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second
|
a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second
|
||||||
// We allocate txTimeoutMultiplier times sqlTimeout for transactions.
|
// We allocate txTimeoutMultiplier times sqlTimeout for transactions.
|
||||||
a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second
|
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
|
return err
|
||||||
}
|
}
|
||||||
@@ -183,8 +240,26 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
|||||||
// Close closes the underlying database connection
|
// Close closes the underlying database connection
|
||||||
func (a *adapter) Close() error {
|
func (a *adapter) Close() error {
|
||||||
var err 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 {
|
if a.db != nil {
|
||||||
err = a.db.Close()
|
if closeErr := a.db.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
a.db = nil
|
a.db = nil
|
||||||
a.version = -1
|
a.version = -1
|
||||||
}
|
}
|
||||||
@@ -285,18 +360,112 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenant t.Tenant
|
var tenant t.Tenant
|
||||||
err := a.db.GetContext(ctx, &tenant,
|
db := a.db
|
||||||
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
|
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)
|
FROM im_tenant WHERE code=? LIMIT 1`, code)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, t.ErrNotFound
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &tenant, nil
|
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.
|
// CreateDb initializes the storage.
|
||||||
func (a *adapter) CreateDb(reset bool) error {
|
func (a *adapter) CreateDb(reset bool) error {
|
||||||
var err error
|
var err error
|
||||||
@@ -348,6 +517,8 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
code VARCHAR(64) NOT NULL,
|
code VARCHAR(64) NOT NULL,
|
||||||
name VARCHAR(128) NOT NULL,
|
name VARCHAR(128) NOT NULL,
|
||||||
tenant_desc VARCHAR(256),
|
tenant_desc VARCHAR(256),
|
||||||
|
db_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
state SMALLINT NOT NULL,
|
state SMALLINT NOT NULL,
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
created_by BIGINT NOT NULL,
|
created_by BIGINT NOT NULL,
|
||||||
@@ -407,8 +578,8 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err = tx.Exec(
|
if _, err = tx.Exec(
|
||||||
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
|
||||||
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,10 +1168,12 @@ func (a *adapter) UpgradeDb() error {
|
|||||||
if _, err := a.db.Exec(
|
if _, err := a.db.Exec(
|
||||||
`CREATE TABLE im_tenant(
|
`CREATE TABLE im_tenant(
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(64) NOT NULL,
|
code VARCHAR(64) NOT NULL,
|
||||||
name VARCHAR(128) NOT NULL,
|
name VARCHAR(128) NOT NULL,
|
||||||
tenant_desc VARCHAR(256),
|
tenant_desc VARCHAR(256),
|
||||||
state SMALLINT NOT NULL,
|
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_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
created_by BIGINT NOT NULL,
|
created_by BIGINT NOT NULL,
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
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(
|
if _, err := a.db.Exec(
|
||||||
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
|
||||||
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1162,6 +1335,10 @@ func (a *adapter) UpgradeDb() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureTenantRoutingColumns(a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if a.version != adpVersion {
|
if a.version != adpVersion {
|
||||||
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
||||||
". DB is still at " + strconv.Itoa(a.version))
|
". DB is still at " + strconv.Itoa(a.version))
|
||||||
@@ -1191,6 +1368,20 @@ func createIMNotificationTables(tx *sql.Tx) error {
|
|||||||
return err
|
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 {
|
func upgradeIMMessageActions(a *adapter) error {
|
||||||
statements := []string{
|
statements := []string{
|
||||||
"ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
|
"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),
|
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
|
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 {
|
for _, stmt := range statements {
|
||||||
if _, err := exec.Exec(stmt); err != nil {
|
if _, err := exec.Exec(stmt); err != nil {
|
||||||
@@ -4608,6 +4822,29 @@ func isMissingDb(err error) bool {
|
|||||||
return ok && myerr.Number == 1049
|
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.
|
// GetTestAdapter returns an adapter object. Useful for running tests.
|
||||||
func GetTestAdapter() *adapter {
|
func GetTestAdapter() *adapter {
|
||||||
return &adapter{}
|
return &adapter{}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ CREATE TABLE im_tenant(
|
|||||||
code VARCHAR(64) NOT NULL,
|
code VARCHAR(64) NOT NULL,
|
||||||
name VARCHAR(128) NOT NULL,
|
name VARCHAR(128) NOT NULL,
|
||||||
tenant_desc VARCHAR(256),
|
tenant_desc VARCHAR(256),
|
||||||
|
db_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
state SMALLINT NOT NULL,
|
state SMALLINT NOT NULL,
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
created_by BIGINT NOT NULL,
|
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
|
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)
|
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
|
||||||
VALUES('default','Default Tenant',NULL,1,0,0);
|
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE kvmeta(
|
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
|
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.
|
# Indexed user tags.
|
||||||
CREATE TABLE usertags(
|
CREATE TABLE usertags(
|
||||||
id INT NOT NULL AUTO_INCREMENT,
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -1549,6 +1549,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
|
|||||||
return msgs, rows.Err()
|
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) {
|
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) {
|
||||||
row := struct {
|
row := struct {
|
||||||
t.Message
|
t.Message
|
||||||
@@ -1577,6 +1640,146 @@ func messageVersion(version int) int {
|
|||||||
return version
|
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 {
|
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) {
|
if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) {
|
||||||
return t.ErrMalformed
|
return t.ErrMalformed
|
||||||
@@ -1954,6 +2157,57 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID,
|
|||||||
return tx.Commit()
|
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) {
|
func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) {
|
||||||
if err := validTenant(id); err != nil {
|
if err := validTenant(id); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -2998,6 +3252,26 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F
|
|||||||
}, nil
|
}, 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 {
|
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() {
|
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
|
||||||
return t.ErrMalformed
|
return t.ErrMalformed
|
||||||
@@ -3044,6 +3318,79 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid)
|
|||||||
}, nil
|
}, 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) {
|
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() {
|
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
|
||||||
return nil, t.ErrMalformed
|
return nil, t.ErrMalformed
|
||||||
@@ -3075,6 +3422,85 @@ func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Ui
|
|||||||
return err
|
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) {
|
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)
|
return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID)
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -18,6 +18,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -131,12 +132,6 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
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())
|
fd, rsc, err := mh.Download(tenantID, req.URL.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
|
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
|
||||||
@@ -145,7 +140,15 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
defer rsc.Close()
|
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)
|
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"))
|
asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt"))
|
||||||
// Force download for html files as a security measure.
|
// Force download for html files as a security measure.
|
||||||
asAttachment = asAttachment ||
|
asAttachment = asAttachment ||
|
||||||
@@ -159,7 +162,7 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
strings.HasPrefix(fd.MimeType, "multipart/") ||
|
strings.HasPrefix(fd.MimeType, "multipart/") ||
|
||||||
strings.HasPrefix(fd.MimeType, "text/")
|
strings.HasPrefix(fd.MimeType, "text/")
|
||||||
if asAttachment {
|
if asAttachment {
|
||||||
wrt.Header().Set("Content-Disposition", "attachment")
|
wrt.Header().Set("Content-Disposition", contentDispositionAttachment(fd))
|
||||||
}
|
}
|
||||||
|
|
||||||
http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc)
|
http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc)
|
||||||
@@ -415,6 +418,11 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
|
|||||||
|
|
||||||
defer rsc.Close()
|
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.Code = http.StatusOK
|
||||||
resp.Text = http.StatusText(http.StatusOK)
|
resp.Text = http.StatusText(http.StatusOK)
|
||||||
resp.Meta.Name = fd.Location
|
resp.Meta.Name = fd.Location
|
||||||
@@ -445,6 +453,46 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
|
|||||||
return err
|
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.
|
// LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP.
|
||||||
func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {
|
func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {
|
||||||
now := types.TimeNow()
|
now := types.TimeNow()
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -782,6 +782,7 @@ func main() {
|
|||||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
|
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/challenges/", serveIMPasswordResetVerify)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
|
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", serveIMSecurityChallenges)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
|
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName)
|
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/search/conversations", serveIMConversationSearch)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
|
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
|
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/topics/", serveIMTopicMessageAction)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
|
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/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", serveIMFriendRequests)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction)
|
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/blocks/", serveIMBlockUser)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends)
|
mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource)
|
mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource)
|
||||||
|
|||||||
@@ -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)
|
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.
|
// GetOne mocks base method.
|
||||||
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
|
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
|
||||||
m.ctrl.T.Helper()
|
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)
|
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.
|
// StartUpload mocks base method.
|
||||||
func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
|
func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, erro
|
|||||||
if !tenantID.IsValid() {
|
if !tenantID.IsValid() {
|
||||||
return nil, types.ErrMalformed
|
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)
|
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, types.ErrUnsupported
|
return nil, types.ErrUnsupported
|
||||||
@@ -160,6 +167,17 @@ func tenantBusinessAdapter(tenantID types.TenantID) (adapter.TenantBusinessAdapt
|
|||||||
if !tenantID.IsValid() {
|
if !tenantID.IsValid() {
|
||||||
return nil, types.ErrMalformed
|
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)
|
tenantAdp, ok := adp.(adapter.TenantBusinessAdapter)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, types.ErrUnsupported
|
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
|
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)
|
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)
|
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.
|
// 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)
|
GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error)
|
||||||
CanSendP2P(tenantID types.TenantID, userID, peerID types.Uid) (bool, string, 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)
|
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
|
UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error
|
||||||
DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) 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{}
|
type relationsMapper struct{}
|
||||||
|
|
||||||
var Relations RelationsPersistenceInterface
|
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) {
|
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 {
|
if req == nil || req.TenantID != tenantID || req.ApplicantID.IsZero() || req.TargetID.IsZero() || req.ApplicantID == req.TargetID {
|
||||||
return nil, types.ErrMalformed
|
return nil, types.ErrMalformed
|
||||||
@@ -1111,6 +1191,20 @@ func (relationsMapper) BlockUser(tenantID types.TenantID, blockerID, blockedID t
|
|||||||
return tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now)
|
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 {
|
func (relationsMapper) UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error {
|
||||||
if blockerID.IsZero() || blockedID.IsZero() || blockerID == blockedID {
|
if blockerID.IsZero() || blockedID.IsZero() || blockerID == blockedID {
|
||||||
return types.ErrMalformed
|
return types.ErrMalformed
|
||||||
@@ -1133,6 +1227,68 @@ func (relationsMapper) DeleteFriend(tenantID types.TenantID, userID, peerID type
|
|||||||
return tenantAdp.TenantFriendshipDeletePair(tenantID, userID, peerID, now)
|
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) {
|
func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) {
|
||||||
ctx := &types.RelationshipContext{
|
ctx := &types.RelationshipContext{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
@@ -1381,6 +1537,20 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type
|
|||||||
return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
|
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.
|
// 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) {
|
func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
|
||||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
@@ -1649,6 +1819,8 @@ type FilePersistenceInterface interface {
|
|||||||
// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
|
// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
|
||||||
// from being garbage collected.
|
// from being garbage collected.
|
||||||
LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error
|
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.
|
// fileMapper is concrete type which implements FilePersistenceInterface.
|
||||||
@@ -1738,6 +1910,17 @@ func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId t
|
|||||||
return nil
|
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.
|
// PersistentCacheInterface is an interface which defines methods used for accessing persistent key-value cache.
|
||||||
type PersistentCacheInterface interface {
|
type PersistentCacheInterface interface {
|
||||||
// Get reads a persistent cache entry.
|
// Get reads a persistent cache entry.
|
||||||
@@ -2004,4 +2187,5 @@ func init() {
|
|||||||
Verifications = verificationMapper{}
|
Verifications = verificationMapper{}
|
||||||
Security = userSecurityMapper{}
|
Security = userSecurityMapper{}
|
||||||
Relations = relationsMapper{}
|
Relations = relationsMapper{}
|
||||||
|
Favorites = favoritesMapper{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -74,3 +74,16 @@ type FriendRequestListItem struct {
|
|||||||
Peer *User
|
Peer *User
|
||||||
Direction string
|
Direction string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserSearchItem struct {
|
||||||
|
User *User
|
||||||
|
Relationship string
|
||||||
|
RequestID string
|
||||||
|
CanAdd bool
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockListItem struct {
|
||||||
|
Block UserBlock
|
||||||
|
User *User
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Tenant struct {
|
|||||||
Code string `db:"code" json:"code"`
|
Code string `db:"code" json:"code"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"`
|
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:"-"`
|
State TenantState `db:"state" json:"-"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"-"`
|
CreatedAt time.Time `db:"created_at" json:"-"`
|
||||||
CreatedBy int64 `db:"created_by" json:"-"`
|
CreatedBy int64 `db:"created_by" json:"-"`
|
||||||
|
|||||||
+5
-1
@@ -290,7 +290,11 @@
|
|||||||
"mysql": {
|
"mysql": {
|
||||||
// MySQL connection settings via DSN.
|
// MySQL connection settings via DSN.
|
||||||
// See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax.
|
// 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.
|
// MySQL connection pool settings.
|
||||||
// Maximum number of open connections to the database. Default: 0 (unlimited).
|
// Maximum number of open connections to the database. Default: 0 (unlimited).
|
||||||
"max_open_conns": 64,
|
"max_open_conns": 64,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"mysql": {
|
"mysql": {
|
||||||
"database": "tinode",
|
"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": {
|
"rethinkdb": {
|
||||||
"database": "tinode",
|
"database": "tinode",
|
||||||
|
|||||||
Reference in New Issue
Block a user