feat: add im home and message action flows

This commit is contained in:
richjohnrambo
2026-08-12 10:36:41 +08:00
parent d239e37f56
commit 5bd35f97a6
21 changed files with 3087 additions and 61 deletions
+71
View File
@@ -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` 中对应章节状态。
+40
View File
@@ -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{
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
+480 -8
View File
@@ -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(&notification); 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
View File
File diff suppressed because it is too large Load Diff
+440
View File
@@ -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
}
+11
View File
@@ -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 {
+4
View File
@@ -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:
+21
View File
@@ -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)
+48 -3
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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:"-"`
}
+50
View File
@@ -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:"-"`
}
+24
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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{
+88
View File
@@ -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
View File
@@ -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,