feat: add im home and message action flows
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# IM 第一、二章后续增强项
|
||||
|
||||
> 日期:2026-08-12
|
||||
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知
|
||||
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
|
||||
|
||||
## 1. 第一章:登录、注册与安全
|
||||
|
||||
### 1.1 当前已完成基线
|
||||
|
||||
1. 企业码解析、租户状态校验和公开租户配置返回。
|
||||
2. 注册、登录前验证码发送与验证。
|
||||
3. 注册验证码 Token 消费,避免绕过验证码直接注册。
|
||||
4. 忘记密码挑战验证与重置确认。
|
||||
5. Token 绑定 `auth_version`,密码重置后旧 Token 失效。
|
||||
6. 登录后敏感操作二次验证挑战。
|
||||
7. 实名认证状态查询与提交,证件信息脱敏/保护存储。
|
||||
8. 独立密码设置与验证,包含弱密码规则和失败锁定。
|
||||
9. 登录后 bootstrap 返回实名、安全与菜单基础能力。
|
||||
|
||||
### 1.2 后续增强项
|
||||
|
||||
| 优先级 | 增强项 | 说明 | 建议验收点 |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | HTTP `Idempotency-Key` 统一存储与请求摘要冲突检测 | 当前部分接口要求幂等键,但还没有统一的 `tenant_id + uid + method + route + key + body_hash` 处理层。 | 相同键相同 body 返回同一结果;相同键不同 body 返回冲突。 |
|
||||
| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 |
|
||||
| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
|
||||
| P0 | 安全事件通知联动 | 密码重置、实名提交/审核、独立密码变更、新设备登录等应写入系统通知。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件。 |
|
||||
| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 |
|
||||
| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 |
|
||||
| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 |
|
||||
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
|
||||
| P2 | 风控与反诈策略接入 | 第一章安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险动作触发二次验证或反诈提示,低风险动作直通。 |
|
||||
|
||||
## 2. 第二章:消息首页、全局导航与系统通知
|
||||
|
||||
### 2.1 当前已完成基线
|
||||
|
||||
1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。
|
||||
2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。
|
||||
3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。
|
||||
4. 消息 Header 基础校验:`x-im-schema`、`type`、`x-im-client-mid`。
|
||||
5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `Head.type=system`。
|
||||
6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。
|
||||
7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。
|
||||
8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。
|
||||
9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。
|
||||
|
||||
### 2.2 后续增强项
|
||||
|
||||
| 优先级 | 增强项 | 说明 | 建议验收点 |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | 编辑/撤回多端实时事件广播 | 当前 HTTP 更新 DB 并返回成功,后续需要向 Topic 在线成员广播编辑/撤回事件。 | A 端撤回/编辑后,B 端无需刷新即可收到事件并更新 UI。 |
|
||||
| P0 | 编辑/撤回事件 Outbox | DB 更新和实时广播应通过事务事件或 Outbox 衔接,避免更新成功但事件丢失。 | 模拟广播失败后,后台任务可补发事件。 |
|
||||
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
|
||||
| P0 | `Idempotency-Key` 接入编辑/撤回 | 编辑/撤回目前依赖乐观版本控制,后续需要同时支持 HTTP 幂等键。 | 客户端超时重试同一请求不会重复写 revision。 |
|
||||
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
|
||||
| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 |
|
||||
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
|
||||
| P1 | 系统通知可信生产通道 | 当前已提供通知存储和读取接口,后续需要统一服务端生产 API/事件消费者,禁止业务散落直写。 | 安全、群、组织等模块通过统一通道创建通知。 |
|
||||
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
|
||||
| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 |
|
||||
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
|
||||
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
|
||||
|
||||
## 3. 统一处理建议
|
||||
|
||||
1. 先做 P0:统一幂等、编辑/撤回实时事件、撤回占位、验证码真实发送与限频。
|
||||
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理。
|
||||
3. 最后做 P2:端到端重连测试、schema 版本演进和体验型优化。
|
||||
4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。
|
||||
@@ -23,6 +23,46 @@ func (s testSecurityStore) BumpAuthVersion(types.TenantID, types.Uid, time.Time)
|
||||
return s.version + 1, s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
func (s testSecurityStore) CreateRealNameSubmission(types.TenantID, *types.RealNameVerification) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) CreateActionToken(types.TenantID, *types.ActionToken) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) ConsumeActionToken(types.TenantID, types.Uid, string, string, string, time.Time) (*types.ActionToken, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
|
||||
func (s testSecurityStore) UpsertIndependentPassword(types.TenantID, *types.IndependentPassword) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) UpdateIndependentPasswordAttempts(types.TenantID, types.Uid, string, int, *time.Time, time.Time) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) CreateSystemNotification(types.TenantID, *types.SystemNotification) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) {
|
||||
return nil, "", s.err
|
||||
}
|
||||
|
||||
func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
func newTestAuthenticator(t *testing.T) *authenticator {
|
||||
t.Helper()
|
||||
conf, err := json.Marshal(map[string]any{
|
||||
|
||||
@@ -274,6 +274,10 @@ type TenantBusinessAdapter interface {
|
||||
TenantFindOne(t.TenantID, string) (string, error)
|
||||
|
||||
TenantMessageSave(t.TenantID, *t.Message) error
|
||||
TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error)
|
||||
TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error)
|
||||
TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error)
|
||||
TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
|
||||
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
|
||||
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
|
||||
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
|
||||
@@ -302,4 +306,15 @@ type TenantBusinessAdapter interface {
|
||||
|
||||
TenantUserSecurityGetAuthVersion(t.TenantID, t.Uid) (int, error)
|
||||
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
|
||||
TenantRealNameGet(t.TenantID, t.Uid) (*t.RealNameVerification, error)
|
||||
TenantRealNameCreate(t.TenantID, *t.RealNameVerification) error
|
||||
TenantActionTokenCreate(t.TenantID, *t.ActionToken) error
|
||||
TenantActionTokenConsume(t.TenantID, t.Uid, string, string, string, time.Time) (*t.ActionToken, error)
|
||||
TenantIndependentPasswordGet(t.TenantID, t.Uid, string) (*t.IndependentPassword, error)
|
||||
TenantIndependentPasswordUpsert(t.TenantID, *t.IndependentPassword) error
|
||||
TenantIndependentPasswordUpdateAttempts(t.TenantID, t.Uid, string, int, *time.Time, time.Time) error
|
||||
|
||||
TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error
|
||||
TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error)
|
||||
TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error
|
||||
}
|
||||
|
||||
+251
-11
@@ -41,7 +41,7 @@ type adapter struct {
|
||||
}
|
||||
|
||||
const (
|
||||
adpVersion = 118
|
||||
adpVersion = 122
|
||||
adapterName = "mysql"
|
||||
|
||||
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
|
||||
@@ -56,6 +56,8 @@ const (
|
||||
txTimeoutMultiplier = 1.5
|
||||
)
|
||||
|
||||
const defaultTenantID = t.TenantID(1)
|
||||
|
||||
type configType struct {
|
||||
// DB connection settings.
|
||||
// See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config
|
||||
@@ -381,6 +383,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
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,
|
||||
@@ -412,6 +415,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE users(
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
state SMALLINT NOT NULL DEFAULT 0,
|
||||
@@ -423,6 +427,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
trusted JSON,
|
||||
tags JSON,
|
||||
PRIMARY KEY(id),
|
||||
INDEX users_tenant_id_id(tenant_id, id),
|
||||
INDEX users_state_stateat(state, stateat),
|
||||
INDEX users_lastseen_updatedat(lastseen, updatedat)
|
||||
)`); err != nil {
|
||||
@@ -443,15 +448,24 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = createIMSecurityTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = createIMNotificationTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Indexed user tags.
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE usertags(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
userid BIGINT NOT NULL,
|
||||
tag VARCHAR(96) NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
INDEX usertags_tag(tag),
|
||||
INDEX usertags_tenant_tag(tenant_id, tag),
|
||||
UNIQUE INDEX usertags_userid_tag(userid, tag)
|
||||
)`); err != nil {
|
||||
return err
|
||||
@@ -461,6 +475,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE devices(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
userid BIGINT NOT NULL,
|
||||
hash CHAR(16) NOT NULL,
|
||||
deviceid TEXT NOT NULL,
|
||||
@@ -469,6 +484,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
lang VARCHAR(8),
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
INDEX devices_tenant_userid(tenant_id, userid),
|
||||
UNIQUE INDEX devices_hash(hash)
|
||||
)`); err != nil {
|
||||
return err
|
||||
@@ -478,6 +494,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE auth(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
uname VARCHAR(32) NOT NULL,
|
||||
userid BIGINT NOT NULL,
|
||||
scheme VARCHAR(16) NOT NULL,
|
||||
@@ -486,8 +503,8 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
expires DATETIME,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
UNIQUE INDEX auth_userid_scheme(userid, scheme),
|
||||
UNIQUE INDEX auth_uname(uname)
|
||||
UNIQUE INDEX auth_tenant_uname(tenant_id, uname),
|
||||
UNIQUE INDEX auth_userid_scheme(userid, scheme)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -496,6 +513,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE topics(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
state SMALLINT NOT NULL DEFAULT 0,
|
||||
@@ -513,8 +531,10 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
tags JSON,
|
||||
aux JSON,
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX topics_tenant_name(tenant_id, name),
|
||||
UNIQUE INDEX topics_name(name),
|
||||
INDEX topics_owner(owner),
|
||||
INDEX topics_tenant_owner(tenant_id, owner),
|
||||
INDEX topics_state_stateat(state, stateat),
|
||||
INDEX topics_name_state_seqid(name, state, seqid)
|
||||
)`); err != nil {
|
||||
@@ -530,11 +550,13 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE topictags(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
topic CHAR(25) NOT NULL,
|
||||
tag VARCHAR(96) NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(topic) REFERENCES topics(name),
|
||||
INDEX topictags_tag(tag),
|
||||
INDEX topictags_tenant_tag(tenant_id, tag),
|
||||
UNIQUE INDEX topictags_topic_tag(topic, tag)
|
||||
)`); err != nil {
|
||||
return err
|
||||
@@ -544,6 +566,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE subscriptions(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
deletedat DATETIME(3),
|
||||
@@ -557,6 +580,8 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
private JSON,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
INDEX subscriptions_tenant_userid(tenant_id, userid),
|
||||
INDEX subscriptions_tenant_topic(tenant_id, topic),
|
||||
UNIQUE INDEX subscriptions_topic_userid(topic, userid),
|
||||
INDEX subscriptions_topic(topic),
|
||||
INDEX subscriptions_deletedat(deletedat),
|
||||
@@ -569,6 +594,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE messages(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
deletedat DATETIME(3),
|
||||
@@ -576,19 +602,46 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
seqid INT NOT NULL,
|
||||
topic CHAR(25) NOT NULL,` +
|
||||
"`from` BIGINT NOT NULL," +
|
||||
`head JSON,
|
||||
`client_msg_id VARCHAR(64),
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3),
|
||||
recalled_by BIGINT,
|
||||
head JSON,
|
||||
content JSON,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(topic) REFERENCES topics(name),
|
||||
INDEX messages_tenant_topic(tenant_id, topic),
|
||||
UNIQUE INDEX messages_sender_client_mid(tenant_id, ` + "`from`" + `, client_msg_id),
|
||||
UNIQUE INDEX messages_topic_seqid(topic, seqid)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE 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
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Deletion log
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE dellog(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
topic CHAR(25) NOT NULL,
|
||||
deletedfor BIGINT NOT NULL DEFAULT 0,
|
||||
delid INT NOT NULL,
|
||||
@@ -596,6 +649,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
hi INT NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(topic) REFERENCES topics(name),
|
||||
INDEX dellog_tenant_topic(tenant_id, topic),
|
||||
INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
|
||||
INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),
|
||||
INDEX dellog_deletedfor(deletedfor)
|
||||
@@ -607,6 +661,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE credentials(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
deletedat DATETIME(3),
|
||||
@@ -619,7 +674,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
retries INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
UNIQUE credentials_uniqueness(synthetic)
|
||||
UNIQUE credentials_tenant_uniqueness(tenant_id, synthetic)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -629,6 +684,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE fileuploads(
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
userid BIGINT,
|
||||
@@ -638,6 +694,7 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
etag VARCHAR(128),
|
||||
location VARCHAR(2048) NOT NULL,
|
||||
PRIMARY KEY(id),
|
||||
INDEX fileuploads_tenant_status(tenant_id, status),
|
||||
INDEX fileuploads_status(status)
|
||||
)`); err != nil {
|
||||
return err
|
||||
@@ -647,12 +704,14 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE filemsglinks(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
fileid BIGINT NOT NULL,
|
||||
msgid INT,
|
||||
topic CHAR(25),
|
||||
userid BIGINT,
|
||||
PRIMARY KEY(id),
|
||||
INDEX filemsglinks_tenant_fileid(tenant_id, fileid),
|
||||
FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE,
|
||||
@@ -984,6 +1043,7 @@ func (a *adapter) UpgradeDb() error {
|
||||
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,
|
||||
@@ -1031,6 +1091,64 @@ func (a *adapter) UpgradeDb() error {
|
||||
}
|
||||
}
|
||||
|
||||
if a.version == 118 {
|
||||
if _, err := a.db.Exec("ALTER TABLE im_verification_codes ADD COLUMN action VARCHAR(64) NOT NULL DEFAULT '' AFTER scene"); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = createIMSecurityTables(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpVersion(a, 119); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if a.version == 119 {
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = createIMNotificationTables(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpVersion(a, 120); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if a.version == 120 {
|
||||
if _, err := a.db.Exec("ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec("CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpVersion(a, 121); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if a.version == 121 {
|
||||
if err := upgradeIMMessageActions(a); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpVersion(a, 122); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if a.version != adpVersion {
|
||||
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
||||
". DB is still at " + strconv.Itoa(a.version))
|
||||
@@ -1038,6 +1156,121 @@ func (a *adapter) UpgradeDb() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createIMNotificationTables(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(
|
||||
`CREATE TABLE 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
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgradeIMMessageActions(a *adapter) error {
|
||||
statements := []string{
|
||||
"ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
|
||||
"ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version",
|
||||
"ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at",
|
||||
`CREATE TABLE 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
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := a.db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createIMSecurityTables(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`CREATE TABLE 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
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`CREATE TABLE 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
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
`CREATE TABLE 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
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create system topic 'sys'.
|
||||
func createSystemTopic(tx *sql.Tx) error {
|
||||
now := t.TimeNow()
|
||||
@@ -1117,9 +1350,12 @@ func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) e
|
||||
// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
|
||||
// false for any other error
|
||||
func (a *adapter) UserCreate(user *t.User) error {
|
||||
if user == nil || user.TenantID.IsZero() {
|
||||
if user == nil {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if user.TenantID.IsZero() {
|
||||
user.TenantID = defaultTenantID
|
||||
}
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
@@ -1955,9 +2191,13 @@ func (a *adapter) TopicCreate(topic *t.Topic) error {
|
||||
|
||||
// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.
|
||||
func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error {
|
||||
if sub == nil || sub.TenantID.IsZero() {
|
||||
if sub == nil {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
tenantID := sub.TenantID
|
||||
if tenantID.IsZero() {
|
||||
tenantID = defaultTenantID
|
||||
}
|
||||
|
||||
isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
|
||||
|
||||
@@ -1966,26 +2206,26 @@ func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error {
|
||||
_, err := tx.Exec(
|
||||
"INSERT INTO subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
|
||||
"VALUES(?,?,?,NULL,?,?,?,?,?)",
|
||||
sub.TenantID, sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
|
||||
tenantID, sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
|
||||
|
||||
if err != nil && isDupe(err) {
|
||||
if undelete {
|
||||
_, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
"delid=0,recvseqid=0,readseqid=0 WHERE tenant_id=? AND topic=? AND userid=?",
|
||||
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.TenantID,
|
||||
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), tenantID,
|
||||
sub.Topic, decoded_uid)
|
||||
} else {
|
||||
_, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
|
||||
"delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND topic=? AND userid=?",
|
||||
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv,
|
||||
sub.TenantID, sub.Topic, decoded_uid)
|
||||
tenantID, sub.Topic, decoded_uid)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && isOwner {
|
||||
// Update topic owner if the subscription is with owner rights.
|
||||
// Don't increment subscriber count here - it's done in TopicShare in bulk.
|
||||
_, err = tx.Exec("UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decoded_uid, sub.TenantID, sub.Topic)
|
||||
_, err = tx.Exec("UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decoded_uid, tenantID, sub.Topic)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
+132
-7
@@ -50,6 +50,7 @@ CREATE TABLE im_verification_codes(
|
||||
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,
|
||||
@@ -83,10 +84,11 @@ CREATE TABLE kvmeta(
|
||||
INDEX kvmeta_createdat_key(createdat, `key`)
|
||||
);
|
||||
|
||||
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "118");
|
||||
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "122");
|
||||
|
||||
CREATE TABLE users(
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
state SMALLINT NOT NULL DEFAULT 0,
|
||||
@@ -95,9 +97,11 @@ CREATE TABLE users(
|
||||
lastseen DATETIME,
|
||||
useragent VARCHAR(255) DEFAULT '',
|
||||
public JSON,
|
||||
trusted JSON,
|
||||
tags JSON, -- Denormalized array of tags
|
||||
|
||||
PRIMARY KEY(id),
|
||||
INDEX users_tenant_id_id(tenant_id, id),
|
||||
INDEX users_state_stateat(state, stateat),
|
||||
INDEX users_lastseen_updatedat(lastseen, updatedat)
|
||||
);
|
||||
@@ -114,21 +118,101 @@ CREATE TABLE im_user_security(
|
||||
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
# Indexed user tags.
|
||||
CREATE TABLE usertags(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
userid BIGINT NOT NULL,
|
||||
tag VARCHAR(96) NOT NULL,
|
||||
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
INDEX usertags_tag(tag),
|
||||
INDEX usertags_tenant_tag(tenant_id, tag),
|
||||
UNIQUE INDEX usertags_userid_tag(userid, tag)
|
||||
);
|
||||
|
||||
# Indexed devices. Normalized into a separate table.
|
||||
CREATE TABLE devices(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
userid BIGINT NOT NULL,
|
||||
hash CHAR(16) NOT NULL,
|
||||
deviceid TEXT NOT NULL,
|
||||
@@ -138,12 +222,14 @@ CREATE TABLE devices(
|
||||
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
INDEX devices_tenant_userid(tenant_id, userid),
|
||||
UNIQUE INDEX devices_hash(hash)
|
||||
);
|
||||
|
||||
# Authentication records for the basic authentication scheme.
|
||||
CREATE TABLE auth(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
uname VARCHAR(32) NOT NULL,
|
||||
userid BIGINT NOT NULL,
|
||||
scheme VARCHAR(16) NOT NULL,
|
||||
@@ -153,14 +239,15 @@ CREATE TABLE auth(
|
||||
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
UNIQUE INDEX auth_userid_scheme(userid, scheme),
|
||||
UNIQUE INDEX auth_uname (uname)
|
||||
UNIQUE INDEX auth_tenant_uname(tenant_id, uname),
|
||||
UNIQUE INDEX auth_userid_scheme(userid, scheme)
|
||||
);
|
||||
|
||||
|
||||
# Topics
|
||||
CREATE TABLE topics(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
touchedat DATETIME(3),
|
||||
@@ -179,27 +266,32 @@ CREATE TABLE topics(
|
||||
aux JSON,
|
||||
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX topics_tenant_name(tenant_id, name),
|
||||
UNIQUE INDEX topics_name (name),
|
||||
INDEX topics_owner(owner),
|
||||
INDEX topics_tenant_owner(tenant_id, owner),
|
||||
INDEX topics_state_stateat(state, stateat),
|
||||
INDEX topics_name_state_seqid ON topics(name, state, seqid)
|
||||
INDEX topics_name_state_seqid(name, state, seqid)
|
||||
);
|
||||
|
||||
# Indexed topic tags.
|
||||
CREATE TABLE topictags(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
topic CHAR(25) NOT NULL,
|
||||
tag VARCHAR(96) NOT NULL,
|
||||
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(topic) REFERENCES topics(name),
|
||||
INDEX topictags_tag (tag),
|
||||
INDEX topictags_tenant_tag(tenant_id, tag),
|
||||
UNIQUE INDEX topictags_topic_tag(topic, tag)
|
||||
);
|
||||
|
||||
# Subscriptions
|
||||
CREATE TABLE subscriptions(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
deletedat DATETIME(3),
|
||||
@@ -214,15 +306,18 @@ CREATE TABLE subscriptions(
|
||||
|
||||
PRIMARY KEY(id) ,
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
INDEX subscriptions_tenant_userid(tenant_id, userid),
|
||||
INDEX subscriptions_tenant_topic(tenant_id, topic),
|
||||
UNIQUE INDEX subscriptions_topic_userid(topic, userid),
|
||||
INDEX subscriptions_topic(topic),
|
||||
INDEX subscriptions_deletedat(deletedat),
|
||||
INDEX subscriptions_user_topic_deletedat ON subscriptions(userid, topic, deletedat)
|
||||
INDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat)
|
||||
);
|
||||
|
||||
# Messages
|
||||
CREATE TABLE messages(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
deletedat DATETIME(3),
|
||||
@@ -230,17 +325,41 @@ CREATE TABLE messages(
|
||||
seqid INT NOT NULL,
|
||||
topic CHAR(25) NOT NULL,
|
||||
`from` BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64),
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3),
|
||||
recalled_by BIGINT,
|
||||
head JSON,
|
||||
content JSON,
|
||||
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(topic) REFERENCES topics(name),
|
||||
INDEX messages_tenant_topic(tenant_id, topic),
|
||||
UNIQUE INDEX messages_sender_client_mid(tenant_id, `from`, client_msg_id),
|
||||
UNIQUE INDEX messages_topic_seqid (topic, seqid)
|
||||
);
|
||||
|
||||
CREATE TABLE 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
|
||||
);
|
||||
|
||||
# Deletion log
|
||||
CREATE TABLE dellog(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
topic CHAR(25) NOT NULL,
|
||||
deletedfor BIGINT NOT NULL DEFAULT 0,
|
||||
delid INT NOT NULL,
|
||||
@@ -249,6 +368,7 @@ CREATE TABLE dellog(
|
||||
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(topic) REFERENCES topics(name),
|
||||
INDEX dellog_tenant_topic(tenant_id, topic),
|
||||
# For getting the list of deleted message ranges
|
||||
INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
|
||||
# Used when getting not-yet-deleted messages(messages LEFT JOIN dellog)
|
||||
@@ -260,6 +380,7 @@ CREATE TABLE dellog(
|
||||
# User credentials
|
||||
CREATE TABLE credentials(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
deletedat DATETIME(3),
|
||||
@@ -272,13 +393,14 @@ CREATE TABLE credentials(
|
||||
retries INT NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE credentials_uniqueness(synthetic),
|
||||
FOREIGN KEY(userid) REFERENCES users(id),
|
||||
UNIQUE credentials_tenant_uniqueness(tenant_id, synthetic)
|
||||
);
|
||||
|
||||
# Records of uploaded files. Files themselves are stored elsewhere.
|
||||
CREATE TABLE fileuploads(
|
||||
id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
updatedat DATETIME(3) NOT NULL,
|
||||
userid BIGINT,
|
||||
@@ -289,12 +411,14 @@ CREATE TABLE fileuploads(
|
||||
etag VARCHAR(128),
|
||||
|
||||
PRIMARY KEY(id),
|
||||
INDEX fileuploads_tenant_status(tenant_id, status),
|
||||
INDEX fileuploads_status(status)
|
||||
);
|
||||
|
||||
# Links between uploaded files and messages or topics.
|
||||
CREATE TABLE filemsglinks(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
createdat DATETIME(3) NOT NULL,
|
||||
fileid BIGINT NOT NULL,
|
||||
msgid INT,
|
||||
@@ -302,8 +426,9 @@ CREATE TABLE filemsglinks(
|
||||
userid BIGINT,
|
||||
|
||||
PRIMARY KEY(id),
|
||||
INDEX filemsglinks_tenant_fileid(tenant_id, fileid),
|
||||
FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(topicid) REFERENCES topics(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE,
|
||||
FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -1344,9 +1347,10 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
|
||||
res, err := a.db.ExecContext(ctx,
|
||||
"INSERT INTO messages(tenant_id,createdat,updatedat,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?,?)",
|
||||
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content))
|
||||
"INSERT INTO messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content))
|
||||
if err == nil {
|
||||
lastID, _ := res.LastInsertId()
|
||||
msg.SetUid(t.Uid(lastID))
|
||||
@@ -1354,6 +1358,128 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
|
||||
if err := validTenant(id); err != nil || from.IsZero() || clientMsgID == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var msg t.Message
|
||||
err := a.db.QueryRowxContext(ctx,
|
||||
"SELECT id,tenant_id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+
|
||||
"FROM messages WHERE tenant_id=? AND `from`=? AND client_msg_id=?",
|
||||
id, decodeUID(from), clientMsgID).StructScan(&msg)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.From = common.EncodeUidString(msg.From).String()
|
||||
msg.Content = common.FromJSON(msg.Content)
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.Message, error) {
|
||||
if err := validTenant(id); err != nil || topic == "" || seq <= 0 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
msg, err := tenantMessageGetOne(a.db, ctx, id, topic, seq, "")
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
|
||||
if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
|
||||
return nil, t.ErrDuplicate
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE tenant_id=? AND topic=? AND seqid=? AND version=?",
|
||||
recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.TenantMessageGetOne(id, topic, seq)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
|
||||
if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
|
||||
return nil, t.ErrDuplicate
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"INSERT INTO im_message_revisions(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE messages SET updatedat=?,version=version+1,head=?,content=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?",
|
||||
editedAt, head, common.ToJSON(content), id, topic, seq, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.TenantMessageGetOne(id, topic, seq)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
|
||||
if err := validTenant(id); err != nil || topic == "" {
|
||||
return nil, t.ErrMalformed
|
||||
@@ -1390,7 +1516,7 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
|
||||
defer cancel()
|
||||
}
|
||||
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.head,m.content "+
|
||||
"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 m.tenant_id=? AND m.delid=0 AND m.topic=? "+
|
||||
seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
|
||||
@@ -1412,6 +1538,34 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) {
|
||||
row := struct {
|
||||
t.Message
|
||||
RecalledBy sql.NullInt64 `db:"recalled_by"`
|
||||
}{}
|
||||
err := sqlx.GetContext(ctx, q, &row,
|
||||
"SELECT id,tenant_id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+
|
||||
"FROM messages WHERE tenant_id=? AND topic=? AND seqid=?"+suffix,
|
||||
tenantID, topic, seq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := row.Message
|
||||
msg.From = common.EncodeUidString(msg.From).String()
|
||||
if row.RecalledBy.Valid {
|
||||
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(row.RecalledBy.Int64, 10)).String()
|
||||
}
|
||||
msg.Content = common.FromJSON(msg.Content)
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func messageVersion(version int) int {
|
||||
if version <= 0 {
|
||||
return 1
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
|
||||
if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) {
|
||||
return t.ErrMalformed
|
||||
@@ -1867,10 +2021,10 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode
|
||||
vc.UpdatedAt = now
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO im_verification_codes
|
||||
(tenant_id,challenge_id,scene,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
|
||||
(tenant_id,challenge_id,scene,action,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
|
||||
expires_at,resend_after,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, vc.ChallengeID, vc.Scene, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, vc.ChallengeID, vc.Scene, vc.Action, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
|
||||
vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
@@ -1891,7 +2045,7 @@ func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.V
|
||||
}
|
||||
var vc t.VerificationCode
|
||||
err := a.db.GetContext(ctx, &vc,
|
||||
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||
`SELECT id,tenant_id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
|
||||
FROM im_verification_codes
|
||||
WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID)
|
||||
@@ -1914,7 +2068,7 @@ func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash st
|
||||
}
|
||||
var vc t.VerificationCode
|
||||
err := a.db.GetContext(ctx, &vc,
|
||||
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||
`SELECT id,tenant_id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
|
||||
FROM im_verification_codes
|
||||
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>?
|
||||
@@ -2058,9 +2212,327 @@ func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, up
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRealNameGet(id t.TenantID, uid t.Uid) (*t.RealNameVerification, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var rv t.RealNameVerification
|
||||
err := a.db.GetContext(ctx, &rv,
|
||||
`SELECT id,tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash,
|
||||
masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at
|
||||
FROM im_realname_verifications
|
||||
WHERE tenant_id=? AND user_id=?
|
||||
ORDER BY created_at DESC,id DESC LIMIT 1`,
|
||||
id, decodeUID(uid))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rv, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification) error {
|
||||
if err := validTenant(id); err != nil || rv == nil || rv.TenantID != id || rv.UserID.IsZero() ||
|
||||
rv.SubmissionID == "" || rv.Status == "" || rv.NameCipher == "" || rv.IDType == "" ||
|
||||
rv.IDNumberCipher == "" || rv.IDNumberHash == "" || rv.MaskedName == "" || rv.MaskedIDNumber == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
rv.CreatedAt = now
|
||||
rv.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_realname_verifications
|
||||
(tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash,
|
||||
masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, decodeUID(rv.UserID), rv.SubmissionID, rv.Status, rv.NameCipher, rv.IDType, rv.IDNumberCipher, rv.IDNumberHash,
|
||||
rv.MaskedName, rv.MaskedIDNumber, rv.ReviewReason, rv.VerifiedAt, rv.CreatedAt, rv.UpdatedAt)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error {
|
||||
if err := validTenant(id); err != nil || token == nil || token.TenantID != id || token.UserID.IsZero() ||
|
||||
token.TokenID == "" || token.Action == "" || token.TokenHash == "" || token.ExpiresAt.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
token.CreatedAt = now
|
||||
token.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_action_tokens
|
||||
(tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State,
|
||||
token.ExpiresAt, token.CreatedAt, token.UpdatedAt)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, action, target string, now time.Time) (*t.ActionToken, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || token == "" || action == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = t.TimeNow()
|
||||
}
|
||||
tokenID, secret, ok := strings.Cut(token, ".")
|
||||
if !ok || tokenID == "" || secret == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
tokenHash := securityTokenHash(id, uid, action, target, tokenID, secret)
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
var at t.ActionToken
|
||||
err = tx.GetContext(ctx, &at,
|
||||
`SELECT id,tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at
|
||||
FROM im_action_tokens
|
||||
WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`,
|
||||
id, decodeUID(uid), tokenID, action, target)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.State != 0 || at.TokenHash != tokenHash || !now.Before(at.ExpiresAt) {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE im_action_tokens SET state=1,consumed_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`,
|
||||
now, now, id, decodeUID(uid), tokenID, action, target, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &at, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose string) (*t.IndependentPassword, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var pass t.IndependentPassword
|
||||
err := a.db.GetContext(ctx, &pass,
|
||||
`SELECT tenant_id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at
|
||||
FROM im_youth_mode_settings
|
||||
WHERE tenant_id=? AND user_id=? AND purpose=?`,
|
||||
id, decodeUID(uid), purpose)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pass, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.IndependentPassword) error {
|
||||
if err := validTenant(id); err != nil || pass == nil || pass.TenantID != id || pass.UserID.IsZero() ||
|
||||
pass.Purpose == "" || len(pass.PasswordHash) == 0 {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
pass.CreatedAt = now
|
||||
pass.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_youth_mode_settings
|
||||
(tenant_id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at)
|
||||
VALUES(?,?,?,?,0,NULL,?,?)
|
||||
ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`,
|
||||
id, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" || attempts < 0 {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = t.TimeNow()
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`UPDATE im_youth_mode_settings SET failed_attempts=?,locked_until=?,updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND purpose=?`,
|
||||
attempts, lockedUntil, updatedAt, id, decodeUID(uid), purpose)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.SystemNotification) error {
|
||||
if err := validTenant(id); err != nil || notification == nil || notification.TenantID != id ||
|
||||
notification.UserID.IsZero() || notification.EventID == "" || notification.EventType == "" ||
|
||||
notification.Title == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
notification.CreatedAt = now
|
||||
notification.UpdatedAt = now
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_system_notifications
|
||||
(tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
event_type=VALUES(event_type),
|
||||
title=VALUES(title),
|
||||
summary=VALUES(summary),
|
||||
action=VALUES(action),
|
||||
updated_at=VALUES(updated_at)`,
|
||||
id, decodeUID(notification.UserID), notification.EventID, notification.EventType, notification.Title,
|
||||
notification.Summary, common.ToJSON(notification.Action), notification.ReadAt, notification.CreatedAt, notification.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.SystemNotification, string, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
lastID := int64(0)
|
||||
if cursor != "" {
|
||||
parsed, err := strconv.ParseInt(cursor, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
lastID = parsed
|
||||
}
|
||||
query := `SELECT id,tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at
|
||||
FROM im_system_notifications
|
||||
WHERE tenant_id=? AND user_id=?`
|
||||
args := []any{id, decodeUID(uid)}
|
||||
if lastID > 0 {
|
||||
query += " AND id<?"
|
||||
args = append(args, lastID)
|
||||
}
|
||||
query += " ORDER BY id DESC LIMIT ?"
|
||||
args = append(args, limit+1)
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
notifications := make([]t.SystemNotification, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
var notification t.SystemNotification
|
||||
if err = rows.StructScan(¬ification); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if action, ok := common.FromJSON(notification.Action).(map[string]any); ok {
|
||||
notification.Action = t.KVMap(action)
|
||||
}
|
||||
if len(notifications) == limit {
|
||||
nextCursor = strconv.FormatInt(notification.ID, 10)
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return notifications, nextCursor, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || eventID == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
if readAt.IsZero() {
|
||||
readAt = t.TimeNow()
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx,
|
||||
`UPDATE im_system_notifications
|
||||
SET read_at=COALESCE(read_at,?),updated_at=?
|
||||
WHERE tenant_id=? AND user_id=? AND event_id=?`,
|
||||
readAt, readAt, id, decodeUID(uid), eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableUID(uid t.Uid) any {
|
||||
if uid.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return store.DecodeUid(uid)
|
||||
}
|
||||
|
||||
func securityTokenHash(tenantID t.TenantID, uid t.Uid, action, target, tokenID, secret string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(strconv.FormatInt(int64(tenantID), 10)))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(strconv.FormatInt(store.DecodeUid(uid), 10)))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(action))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(target))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(tokenID))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(secret))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
+1021
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/tinode/chat/server/auth"
|
||||
"github.com/tinode/chat/server/auth/mock_auth"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/mock_store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type testVerificationStore struct {
|
||||
@@ -94,6 +99,20 @@ func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPRe
|
||||
return resp
|
||||
}
|
||||
|
||||
func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantID types.TenantID, uid types.Uid) *mock_store.MockPersistentStorageInterface {
|
||||
t.Helper()
|
||||
oldStore := store.Store
|
||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
|
||||
tokenAuth.EXPECT().IsInitialized().Return(true)
|
||||
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)).
|
||||
Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil)
|
||||
store.Store = mockStore
|
||||
t.Cleanup(func() { store.Store = oldStore })
|
||||
return mockStore
|
||||
}
|
||||
|
||||
func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
@@ -425,6 +444,427 @@ func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMRealNameReturnsUnverified(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSecurity := store.Security
|
||||
store.Security = &testSecurityStore{}
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/security/realname", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMRealName(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "unverified" || data["can_submit"] != true {
|
||||
t.Fatalf("unexpected realname payload: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions",
|
||||
bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
req.Header.Set("Idempotency-Key", "idem-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMRealNameSubmissions(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.realName == nil || security.realName.Status != "pending" || security.realName.NameCipher == "张三" {
|
||||
t.Fatalf("unexpected stored realname: %#v", security.realName)
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "pending" || data["submission_id"] == "" {
|
||||
t.Fatalf("unexpected response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
expectIMBearer(t, ctrl, "token-set", 7, 42)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password",
|
||||
bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action_token":"action.1"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-set")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMIndependentPassword(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("set status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.independentPassword == nil ||
|
||||
bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil {
|
||||
t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword)
|
||||
}
|
||||
|
||||
expectIMBearer(t, ctrl, "token-verify", 7, 42)
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify",
|
||||
bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action":"close_youth_mode"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-verify")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
serveIMIndependentPasswordVerify(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["action"] != "close_youth_mode" || data["action_token"] == "" {
|
||||
t.Fatalf("unexpected verify response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldVerifications := store.Verifications
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{}
|
||||
store.Security = security
|
||||
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
||||
TenantID: 7,
|
||||
ChallengeID: "challenge-1",
|
||||
Scene: "sensitive_action",
|
||||
Action: "set_independent_password",
|
||||
PhoneHash: "phone-hash",
|
||||
TargetUser: 42,
|
||||
CodeHash: hashIMSecret(7, "sensitive_action", "challenge-1", "phone-hash", "123456"),
|
||||
State: types.VerificationStatePending,
|
||||
MaxAttempts: 5,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
}}
|
||||
defer func() {
|
||||
store.Verifications = oldVerifications
|
||||
store.Security = oldSecurity
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/challenge-1/verify",
|
||||
bytes.NewBufferString(`{"code":"123456"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMSecurityChallengeVerify(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.actionToken == nil || security.actionToken.Action != "set_independent_password" || security.actionToken.Target != "youth_mode" {
|
||||
t.Fatalf("unexpected action token: %#v", security.actionToken)
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["action"] != "set_independent_password" || data["action_token"] == "" {
|
||||
t.Fatalf("unexpected response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSecurity := store.Security
|
||||
store.Security = &testSecurityStore{}
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMBootstrap(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil {
|
||||
t.Fatalf("bootstrap missing home capabilities: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMConversationSearch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldUsers := store.Users
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
store.Users = users
|
||||
defer func() { store.Users = oldUsers }()
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
alice := types.Subscription{
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: now},
|
||||
Topic: "p2pAlice",
|
||||
ReadSeqId: 2,
|
||||
RecvSeqId: 3,
|
||||
}
|
||||
alice.SetPublic(map[string]any{"fn": "Alice"})
|
||||
alice.SetSeqId(5)
|
||||
alice.SetTouchedAt(now)
|
||||
bob := types.Subscription{
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: now},
|
||||
Topic: "grpBob",
|
||||
ReadSeqId: 9,
|
||||
}
|
||||
bob.SetPublic(map[string]any{"fn": "Bob Group"})
|
||||
bob.SetSeqId(9)
|
||||
bob.SetTouchedAt(now)
|
||||
users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{alice, bob}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMConversationSearch(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items length: got %d, want 1; data=%#v", len(items), data)
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["topic"] != "p2pAlice" || item["unread"].(float64) != 3 {
|
||||
t.Fatalf("unexpected conversation item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldSecurity := store.Security
|
||||
security := &testSecurityStore{notifications: []types.SystemNotification{{
|
||||
TenantID: 7,
|
||||
UserID: 42,
|
||||
EventID: "evt-1",
|
||||
EventType: "security.new_device_login",
|
||||
Title: "新设备登录",
|
||||
Summary: "iPhone 登录",
|
||||
Action: types.KVMap{"type": "security_center"},
|
||||
CreatedAt: time.Now().UTC().Round(time.Millisecond),
|
||||
}}}
|
||||
store.Security = security
|
||||
defer func() { store.Security = oldSecurity }()
|
||||
|
||||
expectIMBearer(t, ctrl, "token-list", 7, 42)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-list")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMSystemNotifications(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if len(data["items"].([]any)) != 1 {
|
||||
t.Fatalf("unexpected list response: %#v", data)
|
||||
}
|
||||
|
||||
expectIMBearer(t, ctrl, "token-read", 7, 42)
|
||||
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer token-read")
|
||||
req.Header.Set("Idempotency-Key", "read-1")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
serveIMSystemNotificationRead(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
|
||||
t.Fatalf("notification was not marked read: %#v", security)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageReceipts(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldTopics := store.Topics
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Topics = topics
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Topics = oldTopics
|
||||
}()
|
||||
|
||||
mySub := &types.Subscription{
|
||||
TenantID: 7,
|
||||
User: "42",
|
||||
Topic: "grpReceipt",
|
||||
ModeWant: types.ModeCFull,
|
||||
ModeGiven: types.ModeCFull,
|
||||
}
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpReceipt", types.Uid(42), false).Return(mySub, nil)
|
||||
readMember := types.Subscription{User: "101", ReadSeqId: 8, RecvSeqId: 8}
|
||||
readMember.SetPublic(map[string]any{"fn": "Alice"})
|
||||
deliveredMember := types.Subscription{User: "102", ReadSeqId: 4, RecvSeqId: 8}
|
||||
deliveredMember.SetPublic(map[string]any{"fn": "Bob"})
|
||||
topics.EXPECT().GetUsers(types.TenantID(7), "grpReceipt", gomock.Any()).Return([]types.Subscription{readMember, deliveredMember}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpReceipt/messages/8/receipts", nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMMessageReceipts(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["topic"] != "grpReceipt" || data["seq"].(float64) != 8 {
|
||||
t.Fatalf("unexpected receipt envelope: %#v", data)
|
||||
}
|
||||
if len(data["read"].([]any)) != 1 || len(data["delivered"].([]any)) != 2 {
|
||||
t.Fatalf("unexpected receipt items: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageRecall(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldMessages := store.Messages
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Messages = messages
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Messages = oldMessages
|
||||
}()
|
||||
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpRecall", types.Uid(42), false).Return(&types.Subscription{
|
||||
TenantID: 7,
|
||||
User: "42",
|
||||
Topic: "grpRecall",
|
||||
ModeWant: types.ModeCFull,
|
||||
ModeGiven: types.ModeCFull,
|
||||
}, nil)
|
||||
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 9,
|
||||
Topic: "grpRecall",
|
||||
From: types.Uid(42).String(),
|
||||
Version: 1,
|
||||
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
||||
}, nil)
|
||||
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 9,
|
||||
Topic: "grpRecall",
|
||||
From: "42",
|
||||
Version: 2,
|
||||
RecalledAt: ptrTime(time.Now().UTC()),
|
||||
RecalledBy: "42",
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{"version":1}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMMessageRecall(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "recalled" || data["version"].(float64) != 2 {
|
||||
t.Fatalf("unexpected recall response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMMessageRevision(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldMessages := store.Messages
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Messages = messages
|
||||
defer func() {
|
||||
store.Subs = oldSubs
|
||||
store.Messages = oldMessages
|
||||
}()
|
||||
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpEdit", types.Uid(42), false).Return(&types.Subscription{
|
||||
TenantID: 7,
|
||||
User: "42",
|
||||
Topic: "grpEdit",
|
||||
ModeWant: types.ModeCFull,
|
||||
ModeGiven: types.ModeCFull,
|
||||
}, nil)
|
||||
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 3,
|
||||
Topic: "grpEdit",
|
||||
From: types.Uid(42).String(),
|
||||
Version: 1,
|
||||
Head: types.KVMap{"type": "text"},
|
||||
Content: "old",
|
||||
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
||||
}, nil)
|
||||
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
|
||||
TenantID: 7,
|
||||
SeqId: 3,
|
||||
Topic: "grpEdit",
|
||||
From: "42",
|
||||
Version: 2,
|
||||
Head: types.KVMap{"type": "text"},
|
||||
Content: "new",
|
||||
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpEdit/messages/3/revisions", bytes.NewBufferString(`{"version":1,"content":"new"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMMessageRevision(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["status"] != "edited" || data["version"].(float64) != 2 || data["content"] != "new" {
|
||||
t.Fatalf("unexpected revision response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -782,6 +782,17 @@ func main() {
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges)
|
||||
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/submissions", serveIMRealNameSubmissions)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password", serveIMIndependentPassword)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
|
||||
// Handle long polling clients. Enable compression.
|
||||
mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
|
||||
if config.Media != nil {
|
||||
|
||||
@@ -762,6 +762,10 @@ func (s *Session) publish(msg *ClientComMessage) {
|
||||
logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid)
|
||||
}
|
||||
} else if msg.RcptTo == "sys" {
|
||||
if s.authLvl != auth.LevelRoot {
|
||||
s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp))
|
||||
return
|
||||
}
|
||||
// Publishing to "sys" topic requires no subscription.
|
||||
select {
|
||||
case globals.hub.routeCli <- msg:
|
||||
|
||||
@@ -338,6 +338,27 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSysRequiresRoot(t *testing.T) {
|
||||
s := &Session{
|
||||
send: make(chan any, 1),
|
||||
uid: types.Uid(42),
|
||||
authLvl: auth.LevelAuth,
|
||||
tenantID: 1,
|
||||
tenantCode: "test",
|
||||
}
|
||||
s.publish(&ClientComMessage{
|
||||
Original: "sys",
|
||||
RcptTo: "sys",
|
||||
AsUser: types.Uid(42).UserId(),
|
||||
Timestamp: time.Now(),
|
||||
Pub: &MsgClientPub{Id: "pub-1", Topic: "sys", Content: map[string]any{"type": "system"}},
|
||||
})
|
||||
resp := (<-s.send).(*ServerComMessage)
|
||||
if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRejectsCrossTenantAsUser(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
|
||||
@@ -1098,6 +1098,21 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteList", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).DeleteList), tenantID, topic, delID, forUser, msgDelAge, ranges)
|
||||
}
|
||||
|
||||
// Edit mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
|
||||
ret0, _ := ret[0].(*types.Message)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Edit indicates an expected call of Edit.
|
||||
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Edit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Edit", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Edit), tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
|
||||
}
|
||||
|
||||
// GetAll mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1129,12 +1144,42 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, top
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt)
|
||||
}
|
||||
|
||||
// GetOne mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq)
|
||||
ret0, _ := ret[0].(*types.Message)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetOne indicates an expected call of GetOne.
|
||||
func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic, seq interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOne", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetOne), tenantID, topic, seq)
|
||||
}
|
||||
|
||||
// Recall mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt)
|
||||
ret0, _ := ret[0].(*types.Message)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Recall indicates an expected call of Recall.
|
||||
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Recall(tenantID, topic, seq, actor, expectedVersion, recalledAt interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recall", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Recall), tenantID, topic, seq, actor, expectedVersion, recalledAt)
|
||||
}
|
||||
|
||||
// Save mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {
|
||||
func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Save", tenantID, msg, attachmentURLs, readBySender)
|
||||
ret0, _ := ret[0].(error)
|
||||
ret1, _ := ret[1].(bool)
|
||||
ret0, _ := ret[0].(*types.MessageSaveResult)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
|
||||
+230
-11
@@ -953,7 +953,10 @@ func (subsMapper) Delete(tenantID types.TenantID, topic string, user types.Uid)
|
||||
|
||||
// MessagesPersistenceInterface is an interface which defines methods for persistent storage of messages.
|
||||
type MessagesPersistenceInterface interface {
|
||||
Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool)
|
||||
Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error)
|
||||
GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error)
|
||||
Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error)
|
||||
Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, 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)
|
||||
GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)
|
||||
@@ -965,33 +968,84 @@ type messagesMapper struct{}
|
||||
// Messages is a singleton ancor object for exporting MessagesPersistenceInterface.
|
||||
var Messages MessagesPersistenceInterface
|
||||
|
||||
// Save message
|
||||
func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) {
|
||||
if msg == nil || msg.TenantID != tenantID {
|
||||
return types.ErrMalformed, false
|
||||
func (messagesMapper) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantMessageGetOne(tenantID, topic, seq)
|
||||
}
|
||||
|
||||
func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt)
|
||||
}
|
||||
|
||||
func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
|
||||
if err := validateMessageHeaders(head); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err, false
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
|
||||
}
|
||||
|
||||
// Save message
|
||||
func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
|
||||
if msg == nil || msg.TenantID != tenantID {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientMsgID, err := normalizeClientMsgID(msg.Head)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.ClientMsgID = clientMsgID
|
||||
fromUid := types.ParseUid(msg.From)
|
||||
if clientMsgID != "" {
|
||||
duplicate, err := tenantAdp.TenantMessageGetByClientID(tenantID, fromUid, clientMsgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if duplicate != nil {
|
||||
msg.ObjHeader = duplicate.ObjHeader
|
||||
msg.DeletedAt = duplicate.DeletedAt
|
||||
msg.DelId = duplicate.DelId
|
||||
msg.DeletedFor = duplicate.DeletedFor
|
||||
msg.SeqId = duplicate.SeqId
|
||||
msg.Topic = duplicate.Topic
|
||||
msg.From = duplicate.From
|
||||
msg.ClientMsgID = duplicate.ClientMsgID
|
||||
msg.Head = duplicate.Head
|
||||
msg.Content = duplicate.Content
|
||||
return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil
|
||||
}
|
||||
}
|
||||
msg.InitTimes()
|
||||
msg.SetUid(Store.GetUid())
|
||||
// Increment topic's or user's SeqId
|
||||
err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg)
|
||||
if err != nil {
|
||||
return err, false
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tenantAdp.TenantMessageSave(tenantID, msg)
|
||||
if err != nil {
|
||||
return err, false
|
||||
return nil, err
|
||||
}
|
||||
|
||||
markedReadBySender := false
|
||||
// Mark message as read by the sender.
|
||||
if readBySender {
|
||||
// Make sure From is valid, otherwise we will reset values for all subscribers.
|
||||
fromUid := types.ParseUid(msg.From)
|
||||
if !fromUid.IsZero() {
|
||||
// Ignore the error here. It's not a big deal if it fails.
|
||||
if subErr := tenantAdp.TenantSubsUpdate(tenantID, msg.Topic, fromUid,
|
||||
@@ -1014,11 +1068,74 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme
|
||||
}
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
return tenantAdp.TenantFileLinkAttachments(tenantID, "", types.ZeroUid, msg.Uid(), attachments), markedReadBySender
|
||||
if err := tenantAdp.TenantFileLinkAttachments(tenantID, "", types.ZeroUid, msg.Uid(), attachments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, markedReadBySender
|
||||
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: markedReadBySender}, nil
|
||||
}
|
||||
|
||||
func normalizeClientMsgID(head types.KVMap) (string, error) {
|
||||
if head == nil {
|
||||
return "", nil
|
||||
}
|
||||
if err := validateMessageHeaders(head); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, ok := head["x-im-client-mid"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
clientMsgID, ok := value.(string)
|
||||
if !ok {
|
||||
return "", types.ErrMalformed
|
||||
}
|
||||
clientMsgID = strings.TrimSpace(clientMsgID)
|
||||
if len(clientMsgID) > 64 {
|
||||
return "", types.ErrMalformed
|
||||
}
|
||||
return clientMsgID, nil
|
||||
}
|
||||
|
||||
func validateMessageHeaders(head types.KVMap) error {
|
||||
if schema, ok := head["x-im-schema"]; ok {
|
||||
value, ok := schema.(string)
|
||||
if !ok || !validIMMessageHeaderToken(value, 32) {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
}
|
||||
if msgType, ok := head["type"]; ok {
|
||||
value, ok := msgType.(string)
|
||||
if !ok || !validIMMessageType(value) {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validIMMessageHeaderToken(value string, max int) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > max {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validIMMessageType(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "text", "image", "file", "audio", "video", "location", "contact", "card", "call", "system":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteList deletes multiple messages defined by a list of ranges.
|
||||
@@ -1469,6 +1586,16 @@ var Verifications VerificationPersistenceInterface
|
||||
type UserSecurityPersistenceInterface interface {
|
||||
GetAuthVersion(tenantID types.TenantID, uid types.Uid) (int, error)
|
||||
BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error)
|
||||
GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error)
|
||||
CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error
|
||||
CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error
|
||||
ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error)
|
||||
GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error)
|
||||
UpsertIndependentPassword(tenantID types.TenantID, pass *types.IndependentPassword) error
|
||||
UpdateIndependentPasswordAttempts(tenantID types.TenantID, uid types.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error
|
||||
CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error
|
||||
ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error)
|
||||
MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error
|
||||
}
|
||||
|
||||
type userSecurityMapper struct{}
|
||||
@@ -1492,6 +1619,98 @@ func (userSecurityMapper) BumpAuthVersion(tenantID types.TenantID, uid types.Uid
|
||||
return tenantAdp.TenantUserSecurityBumpAuthVersion(tenantID, uid, updatedAt)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantRealNameGet(tenantID, uid)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error {
|
||||
if rv == nil || rv.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantRealNameCreate(tenantID, rv)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error {
|
||||
if token == nil || token.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantActionTokenCreate(tenantID, token)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantActionTokenConsume(tenantID, uid, token, action, target, now)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantIndependentPasswordGet(tenantID, uid, purpose)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) UpsertIndependentPassword(tenantID types.TenantID, pass *types.IndependentPassword) error {
|
||||
if pass == nil || pass.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantIndependentPasswordUpsert(tenantID, pass)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) UpdateIndependentPasswordAttempts(tenantID types.TenantID, uid types.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantIndependentPasswordUpdateAttempts(tenantID, uid, purpose, attempts, lockedUntil, updatedAt)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error {
|
||||
if notification == nil || notification.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantSystemNotificationCreate(tenantID, notification)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tenantAdp.TenantSystemNotificationList(tenantID, uid, cursor, limit)
|
||||
}
|
||||
|
||||
func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantSystemNotificationMarkRead(tenantID, uid, eventID, readAt)
|
||||
}
|
||||
|
||||
func (verificationMapper) Create(tenantID types.TenantID, vc *types.VerificationCode) error {
|
||||
if vc == nil || vc.TenantID != tenantID {
|
||||
return types.ErrMalformed
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemNotification is a product-level notification shown in the system inbox.
|
||||
type SystemNotification struct {
|
||||
ID int64 `db:"id" json:"-"`
|
||||
TenantID TenantID `db:"tenant_id" json:"-"`
|
||||
UserID Uid `db:"user_id" json:"-"`
|
||||
EventID string `db:"event_id" json:"id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Summary string `db:"summary" json:"summary"`
|
||||
Action KVMap `db:"action" json:"action,omitempty"`
|
||||
ReadAt *time.Time `db:"read_at" json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// RealNameVerification is the latest real-name verification submission for a user.
|
||||
type RealNameVerification struct {
|
||||
ID int64 `db:"id" json:"-"`
|
||||
TenantID TenantID `db:"tenant_id" json:"-"`
|
||||
UserID Uid `db:"user_id" json:"-"`
|
||||
SubmissionID string `db:"submission_id" json:"submission_id"`
|
||||
Status string `db:"status" json:"status"`
|
||||
NameCipher string `db:"name_cipher" json:"-"`
|
||||
IDType string `db:"id_type" json:"id_type,omitempty"`
|
||||
IDNumberCipher string `db:"id_number_cipher" json:"-"`
|
||||
IDNumberHash string `db:"id_number_hash" json:"-"`
|
||||
MaskedName string `db:"masked_name" json:"masked_name,omitempty"`
|
||||
MaskedIDNumber string `db:"masked_id_number" json:"masked_id_number,omitempty"`
|
||||
ReviewReason *string `db:"review_reason" json:"review_reason,omitempty"`
|
||||
VerifiedAt *time.Time `db:"verified_at" json:"verified_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"-"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"-"`
|
||||
}
|
||||
|
||||
// ActionToken is a short-lived one-time token for sensitive actions.
|
||||
type ActionToken struct {
|
||||
ID int64 `db:"id" json:"-"`
|
||||
TenantID TenantID `db:"tenant_id" json:"-"`
|
||||
UserID Uid `db:"user_id" json:"-"`
|
||||
TokenID string `db:"token_id" json:"-"`
|
||||
Action string `db:"action" json:"action"`
|
||||
Target string `db:"target" json:"target,omitempty"`
|
||||
TokenHash string `db:"token_hash" json:"-"`
|
||||
State int16 `db:"state" json:"-"`
|
||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||
ConsumedAt *time.Time `db:"consumed_at" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"-"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"-"`
|
||||
}
|
||||
|
||||
// IndependentPassword stores the hashed PIN for a product feature such as youth mode.
|
||||
type IndependentPassword struct {
|
||||
TenantID TenantID `db:"tenant_id" json:"-"`
|
||||
UserID Uid `db:"user_id" json:"-"`
|
||||
Purpose string `db:"purpose" json:"purpose"`
|
||||
PasswordHash []byte `db:"password_hash" json:"-"`
|
||||
FailedAttempts int `db:"failed_attempts" json:"-"`
|
||||
LockedUntil *time.Time `db:"locked_until" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"-"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"-"`
|
||||
}
|
||||
@@ -1229,10 +1229,34 @@ type Message struct {
|
||||
Topic string
|
||||
// Sender's user ID as string (without 'usr' prefix), could be empty.
|
||||
From string
|
||||
ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"`
|
||||
Version int `json:"Version,omitempty" bson:",omitempty"`
|
||||
RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"`
|
||||
RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"`
|
||||
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
|
||||
Content any
|
||||
}
|
||||
|
||||
// MessageRevision stores an immutable snapshot of a message before editing.
|
||||
type MessageRevision struct {
|
||||
ObjHeader `bson:",inline"`
|
||||
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
|
||||
Topic string
|
||||
SeqId int
|
||||
Version int
|
||||
Actor string
|
||||
Head KVMap `json:"Head,omitempty" bson:",omitempty"`
|
||||
Content any
|
||||
Reason string
|
||||
}
|
||||
|
||||
// MessageSaveResult describes the outcome of a message persistence attempt.
|
||||
type MessageSaveResult struct {
|
||||
SeqId int
|
||||
MarkedReadBySender bool
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi).
|
||||
// If the range contains just one ID, Hi is set to 0
|
||||
type Range struct {
|
||||
|
||||
@@ -19,6 +19,7 @@ type VerificationCode struct {
|
||||
TenantID TenantID `db:"tenant_id" json:"-"`
|
||||
ChallengeID string `db:"challenge_id" json:"challenge_id"`
|
||||
Scene string `db:"scene" json:"scene"`
|
||||
Action string `db:"action" json:"action,omitempty"`
|
||||
PhoneHash string `db:"phone_hash" json:"-"`
|
||||
PhoneMask string `db:"phone_mask" json:"phone_mask,omitempty"`
|
||||
TargetUser Uid `db:"target_user" json:"-"`
|
||||
|
||||
+24
-7
@@ -1013,8 +1013,12 @@ func (t *Topic) sendSubNotifications(asUid types.Uid, sid, userAgent string) {
|
||||
// in response to a client request (msg, asUid) and broadcasts it to the attached sessions.
|
||||
func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, noEcho bool, attachments []string, head map[string]any, content any) error {
|
||||
pud, userFound := t.perUser[asUid]
|
||||
// Anyone is allowed to post to 'sys' topic.
|
||||
if t.cat != types.TopicCatSys {
|
||||
if t.cat == types.TopicCatSys {
|
||||
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
|
||||
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
|
||||
return types.ErrPermissionDenied
|
||||
}
|
||||
} else {
|
||||
// If it's not 'sys' check write permission.
|
||||
if !(pud.modeWant & pud.modeGiven).IsWriter() {
|
||||
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
|
||||
@@ -1032,9 +1036,14 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
|
||||
// Make sure the received Head does not include a fake "sender" header.
|
||||
delete(head, "sender")
|
||||
}
|
||||
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
|
||||
if msgType, _ := head["type"].(string); msgType == "system" {
|
||||
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
|
||||
return types.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
markedReadBySender := false
|
||||
if err, unreadUpdated := store.Messages.Save(t.tenantID,
|
||||
saveResult, err := store.Messages.Save(t.tenantID,
|
||||
&types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp},
|
||||
TenantID: t.tenantID,
|
||||
@@ -1043,14 +1052,22 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
|
||||
From: asUid.String(),
|
||||
Head: head,
|
||||
Content: content,
|
||||
}, attachments, (pud.modeGiven & pud.modeWant).IsReader()); err != nil {
|
||||
}, attachments, (pud.modeGiven & pud.modeWant).IsReader())
|
||||
if err != nil {
|
||||
logs.Warn.Printf("topic[%s]: failed to save message: %v", t.name, err)
|
||||
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
|
||||
|
||||
return err
|
||||
} else {
|
||||
markedReadBySender = unreadUpdated
|
||||
}
|
||||
if saveResult != nil && saveResult.Duplicate {
|
||||
if msg.Id != "" && msg.sess != nil {
|
||||
reply := NoErrAccepted(msg.Id, t.original(asUid), msg.Timestamp)
|
||||
reply.Ctrl.Params = map[string]any{"seq": saveResult.SeqId}
|
||||
msg.sess.queueOut(reply)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender
|
||||
|
||||
t.lastID++
|
||||
t.touched = msg.Timestamp
|
||||
|
||||
+111
-7
@@ -200,7 +200,8 @@ func TestHandleBroadcastDataP2P(t *testing.T) {
|
||||
helper := TopicTestHelper{}
|
||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
|
||||
defer helper.tearDown()
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||
|
||||
from := helper.uids[0].UserId()
|
||||
msg := &ClientComMessage{
|
||||
@@ -285,7 +286,8 @@ func TestHandleBroadcastCall(t *testing.T) {
|
||||
globals.iceServers = []iceServer{{Username: "dummy"}}
|
||||
helper.topic.lastID = 5
|
||||
defer helper.tearDown()
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||
|
||||
from := helper.uids[0].UserId()
|
||||
msg := &ClientComMessage{
|
||||
@@ -397,7 +399,8 @@ func TestHandleBroadcastDataGroup(t *testing.T) {
|
||||
store.Messages = nil
|
||||
helper.tearDown()
|
||||
}()
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||
|
||||
// User 3 isn't allowed to read.
|
||||
pu3 := helper.topic.perUser[helper.uids[3]]
|
||||
@@ -557,7 +560,7 @@ func TestHandleBroadcastDataDbError(t *testing.T) {
|
||||
defer helper.tearDown()
|
||||
|
||||
// DB returns an error.
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(types.ErrInternal, false)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, types.ErrInternal)
|
||||
|
||||
// Make test message.
|
||||
from := helper.uids[0].UserId()
|
||||
@@ -605,6 +608,104 @@ func TestHandleBroadcastDataDbError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
|
||||
numUsers := 2
|
||||
helper := TopicTestHelper{}
|
||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
|
||||
defer helper.tearDown()
|
||||
helper.topic.lastID = 7
|
||||
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 4, Duplicate: true}, nil)
|
||||
|
||||
from := helper.uids[0].UserId()
|
||||
msg := &ClientComMessage{
|
||||
Id: "retry-1",
|
||||
AsUser: from,
|
||||
Pub: &MsgClientPub{
|
||||
Topic: "p2p",
|
||||
Head: map[string]any{"x-im-client-mid": "mid-1"},
|
||||
Content: "test",
|
||||
},
|
||||
sess: helper.sessions[0],
|
||||
}
|
||||
|
||||
helper.topic.handleClientMsg(msg)
|
||||
helper.finish()
|
||||
|
||||
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
|
||||
t.Fatal(errorMsgs[0].Ctrl.Text)
|
||||
}
|
||||
if helper.topic.lastID != 7 {
|
||||
t.Errorf("Topic.lastID: expected duplicate to keep 7, found %d", helper.topic.lastID)
|
||||
}
|
||||
if len(helper.results[0].messages) != 1 {
|
||||
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
|
||||
}
|
||||
ack := helper.results[0].messages[0].(*ServerComMessage)
|
||||
if ack.Ctrl == nil {
|
||||
t.Fatal("User 1 is expected to receive a ctrl message")
|
||||
}
|
||||
if ack.Ctrl.Code != 202 {
|
||||
t.Errorf("User1: expected ctrl.code 202, received %d", ack.Ctrl.Code)
|
||||
}
|
||||
params, ok := ack.Ctrl.Params.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params)
|
||||
}
|
||||
if seq, ok := params["seq"].(int); !ok || seq != 4 {
|
||||
t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"])
|
||||
}
|
||||
if len(helper.results[1].messages) != 0 {
|
||||
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
|
||||
}
|
||||
if len(helper.hubMessages) != 0 {
|
||||
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
|
||||
numUsers := 2
|
||||
helper := TopicTestHelper{}
|
||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
|
||||
defer helper.tearDown()
|
||||
|
||||
from := helper.uids[0].UserId()
|
||||
msg := &ClientComMessage{
|
||||
Id: "sys-1",
|
||||
AsUser: from,
|
||||
Pub: &MsgClientPub{
|
||||
Topic: "p2p",
|
||||
Head: map[string]any{"type": "system"},
|
||||
Content: "test",
|
||||
},
|
||||
sess: helper.sessions[0],
|
||||
}
|
||||
|
||||
helper.topic.handleClientMsg(msg)
|
||||
helper.finish()
|
||||
|
||||
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
|
||||
t.Fatal(errorMsgs[0].Ctrl.Text)
|
||||
}
|
||||
if helper.topic.lastID != 0 {
|
||||
t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID)
|
||||
}
|
||||
if len(helper.results[0].messages) != 1 {
|
||||
t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages))
|
||||
}
|
||||
resp := helper.results[0].messages[0].(*ServerComMessage)
|
||||
if resp.Ctrl == nil || resp.Ctrl.Code != 403 {
|
||||
t.Fatalf("User 1 expected 403 ctrl, received %#v", resp)
|
||||
}
|
||||
if len(helper.results[1].messages) != 0 {
|
||||
t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages))
|
||||
}
|
||||
if len(helper.hubMessages) != 0 {
|
||||
t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
|
||||
numUsers := 2
|
||||
helper := TopicTestHelper{}
|
||||
@@ -2876,7 +2977,8 @@ func TestHandleBroadcastDataWithAttachments(t *testing.T) {
|
||||
helper := TopicTestHelper{}
|
||||
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true)
|
||||
defer helper.tearDown()
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||
|
||||
from := helper.uids[0].UserId()
|
||||
msg := &ClientComMessage{
|
||||
@@ -3039,7 +3141,8 @@ func TestHandleBroadcastDataGroupWithMutedUser(t *testing.T) {
|
||||
helper := TopicTestHelper{}
|
||||
helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true)
|
||||
defer helper.tearDown()
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||
|
||||
// User 2 has muted the topic (no Pres permission)
|
||||
pu2 := helper.topic.perUser[helper.uids[2]]
|
||||
@@ -3103,7 +3206,8 @@ func TestUnregisterSessionWithPendingCall(t *testing.T) {
|
||||
isOriginator: true,
|
||||
sess: s,
|
||||
}
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true)
|
||||
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil)
|
||||
|
||||
leave := &ClientComMessage{
|
||||
Leave: &MsgClientLeave{
|
||||
|
||||
@@ -210,6 +210,14 @@ type testSecurityStore struct {
|
||||
err error
|
||||
bumped bool
|
||||
bumpedUser types.Uid
|
||||
|
||||
realName *types.RealNameVerification
|
||||
actionToken *types.ActionToken
|
||||
independentPassword *types.IndependentPassword
|
||||
attempts int
|
||||
lockedUntil *time.Time
|
||||
notifications []types.SystemNotification
|
||||
readNotificationID string
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
|
||||
@@ -228,3 +236,83 @@ func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ t
|
||||
s.version++
|
||||
return s.version, s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.realName == nil {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
return s.realName, nil
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types.RealNameVerification) error {
|
||||
s.realName = rv
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error {
|
||||
s.actionToken = token
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _ string, action string, target string, _ time.Time) (*types.ActionToken, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.actionToken == nil {
|
||||
return &types.ActionToken{Action: action, Target: target}, nil
|
||||
}
|
||||
return s.actionToken, nil
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.independentPassword == nil {
|
||||
return nil, types.ErrNotFound
|
||||
}
|
||||
return s.independentPassword, nil
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) UpsertIndependentPassword(_ types.TenantID, pass *types.IndependentPassword) error {
|
||||
s.independentPassword = pass
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, attempts int, lockedUntil *time.Time, _ time.Time) error {
|
||||
s.attempts = attempts
|
||||
s.lockedUntil = lockedUntil
|
||||
if s.independentPassword != nil {
|
||||
s.independentPassword.FailedAttempts = attempts
|
||||
s.independentPassword.LockedUntil = lockedUntil
|
||||
}
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error {
|
||||
if notification != nil {
|
||||
s.notifications = append(s.notifications, *notification)
|
||||
}
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) {
|
||||
return s.notifications, "", s.err
|
||||
}
|
||||
|
||||
func (s *testSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) error {
|
||||
s.readNotificationID = eventID
|
||||
for i := range s.notifications {
|
||||
if s.notifications[i].EventID == eventID {
|
||||
s.notifications[i].ReadAt = &readAt
|
||||
return s.err
|
||||
}
|
||||
}
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
return types.ErrNotFound
|
||||
}
|
||||
|
||||
+4
-4
@@ -334,7 +334,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
if timestamp.After(now) {
|
||||
now = timestamp
|
||||
}
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
if _, err = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: timestamp},
|
||||
TenantID: tenantID,
|
||||
SeqId: seqId,
|
||||
@@ -357,7 +357,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
|
||||
for _, gt := range data.Grouptopics {
|
||||
seqIds[nameIndex[gt.Name]] = 1
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
if _, err = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: now},
|
||||
TenantID: tenantID,
|
||||
SeqId: 1,
|
||||
@@ -377,7 +377,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
}
|
||||
usedp2p[nameIndex[sub.pair]] = true
|
||||
seqIds[nameIndex[sub.pair]] = 1
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
if _, err = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: now},
|
||||
TenantID: tenantID,
|
||||
SeqId: 1,
|
||||
@@ -401,7 +401,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
seqIds[nameIndex[sub.pair]]++
|
||||
seqId := seqIds[nameIndex[sub.pair]]
|
||||
if sub.Users[0].Name == botAccount || sub.Users[1].Name == botAccount {
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
if _, err = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: ts},
|
||||
TenantID: tenantID,
|
||||
SeqId: seqId,
|
||||
|
||||
Reference in New Issue
Block a user