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