From 5bd35f97a6c921e89e3606e9702ae392a9cf69e6 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 12 Aug 2026 10:36:41 +0800 Subject: [PATCH] feat: add im home and message action flows --- docs/IM-第一二章后续增强项.md | 71 ++ server/auth/token/auth_token_test.go | 40 + server/db/adapter.go | 15 + server/db/mysql/adapter.go | 262 ++++++- server/db/mysql/schema.sql | 139 +++- server/db/mysql/tenant_adapter.go | 488 +++++++++++- server/im_http.go | 1021 +++++++++++++++++++++++++ server/im_http_test.go | 440 +++++++++++ server/main.go | 11 + server/session.go | 4 + server/session_test.go | 21 + server/store/mock_store/mock_store.go | 51 +- server/store/store.go | 241 +++++- server/store/types/notification.go | 18 + server/store/types/security.go | 50 ++ server/store/types/types.go | 30 +- server/store/types/verification.go | 1 + server/topic.go | 31 +- server/topic_test.go | 118 ++- server/user_verification_test.go | 88 +++ tinode-db/gendb.go | 8 +- 21 files changed, 3087 insertions(+), 61 deletions(-) create mode 100644 docs/IM-第一二章后续增强项.md create mode 100644 server/store/types/notification.go create mode 100644 server/store/types/security.go diff --git a/docs/IM-第一二章后续增强项.md b/docs/IM-第一二章后续增强项.md new file mode 100644 index 0000000..5c75917 --- /dev/null +++ b/docs/IM-第一二章后续增强项.md @@ -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` 中对应章节状态。 diff --git a/server/auth/token/auth_token_test.go b/server/auth/token/auth_token_test.go index 7aa5443..1d4be77 100644 --- a/server/auth/token/auth_token_test.go +++ b/server/auth/token/auth_token_test.go @@ -23,6 +23,46 @@ func (s testSecurityStore) BumpAuthVersion(types.TenantID, types.Uid, time.Time) return s.version + 1, s.err } +func (s testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) { + return nil, types.ErrNotFound +} + +func (s testSecurityStore) CreateRealNameSubmission(types.TenantID, *types.RealNameVerification) error { + return s.err +} + +func (s testSecurityStore) CreateActionToken(types.TenantID, *types.ActionToken) error { + return s.err +} + +func (s testSecurityStore) ConsumeActionToken(types.TenantID, types.Uid, string, string, string, time.Time) (*types.ActionToken, error) { + return nil, s.err +} + +func (s testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) { + return nil, types.ErrNotFound +} + +func (s testSecurityStore) UpsertIndependentPassword(types.TenantID, *types.IndependentPassword) error { + return s.err +} + +func (s testSecurityStore) UpdateIndependentPasswordAttempts(types.TenantID, types.Uid, string, int, *time.Time, time.Time) error { + return s.err +} + +func (s testSecurityStore) CreateSystemNotification(types.TenantID, *types.SystemNotification) error { + return s.err +} + +func (s testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) { + return nil, "", s.err +} + +func (s testSecurityStore) MarkSystemNotificationRead(types.TenantID, types.Uid, string, time.Time) error { + return s.err +} + func newTestAuthenticator(t *testing.T) *authenticator { t.Helper() conf, err := json.Marshal(map[string]any{ diff --git a/server/db/adapter.go b/server/db/adapter.go index 1e78285..c911c01 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -274,6 +274,10 @@ type TenantBusinessAdapter interface { TenantFindOne(t.TenantID, string) (string, error) TenantMessageSave(t.TenantID, *t.Message) error + TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error) + TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error) + TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error) + TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error) TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error) TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error) @@ -302,4 +306,15 @@ type TenantBusinessAdapter interface { TenantUserSecurityGetAuthVersion(t.TenantID, t.Uid) (int, error) TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error) + TenantRealNameGet(t.TenantID, t.Uid) (*t.RealNameVerification, error) + TenantRealNameCreate(t.TenantID, *t.RealNameVerification) error + TenantActionTokenCreate(t.TenantID, *t.ActionToken) error + TenantActionTokenConsume(t.TenantID, t.Uid, string, string, string, time.Time) (*t.ActionToken, error) + TenantIndependentPasswordGet(t.TenantID, t.Uid, string) (*t.IndependentPassword, error) + TenantIndependentPasswordUpsert(t.TenantID, *t.IndependentPassword) error + TenantIndependentPasswordUpdateAttempts(t.TenantID, t.Uid, string, int, *time.Time, time.Time) error + + TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error + TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error) + TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 1138c28..8d6b8b5 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -41,7 +41,7 @@ type adapter struct { } const ( - adpVersion = 118 + adpVersion = 122 adapterName = "mysql" defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true" @@ -56,6 +56,8 @@ const ( txTimeoutMultiplier = 1.5 ) +const defaultTenantID = t.TenantID(1) + type configType struct { // DB connection settings. // See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config @@ -381,6 +383,7 @@ func (a *adapter) CreateDb(reset bool) error { tenant_id BIGINT NOT NULL, challenge_id VARCHAR(64) NOT NULL, scene VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL DEFAULT '', phone_hash CHAR(64) NOT NULL, phone_mask VARCHAR(32) NOT NULL, target_user BIGINT, @@ -412,6 +415,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE users( id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, state SMALLINT NOT NULL DEFAULT 0, @@ -423,6 +427,7 @@ func (a *adapter) CreateDb(reset bool) error { trusted JSON, tags JSON, PRIMARY KEY(id), + INDEX users_tenant_id_id(tenant_id, id), INDEX users_state_stateat(state, stateat), INDEX users_lastseen_updatedat(lastseen, updatedat) )`); err != nil { @@ -443,15 +448,24 @@ func (a *adapter) CreateDb(reset bool) error { return err } + if err = createIMSecurityTables(tx); err != nil { + return err + } + if err = createIMNotificationTables(tx); err != nil { + return err + } + // Indexed user tags. if _, err = tx.Exec( `CREATE TABLE usertags( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, userid BIGINT NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), INDEX usertags_tag(tag), + INDEX usertags_tenant_tag(tenant_id, tag), UNIQUE INDEX usertags_userid_tag(userid, tag) )`); err != nil { return err @@ -461,6 +475,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE devices( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, userid BIGINT NOT NULL, hash CHAR(16) NOT NULL, deviceid TEXT NOT NULL, @@ -469,6 +484,7 @@ func (a *adapter) CreateDb(reset bool) error { lang VARCHAR(8), PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), + INDEX devices_tenant_userid(tenant_id, userid), UNIQUE INDEX devices_hash(hash) )`); err != nil { return err @@ -478,6 +494,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE auth( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, uname VARCHAR(32) NOT NULL, userid BIGINT NOT NULL, scheme VARCHAR(16) NOT NULL, @@ -486,8 +503,8 @@ func (a *adapter) CreateDb(reset bool) error { expires DATETIME, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE INDEX auth_userid_scheme(userid, scheme), - UNIQUE INDEX auth_uname(uname) + UNIQUE INDEX auth_tenant_uname(tenant_id, uname), + UNIQUE INDEX auth_userid_scheme(userid, scheme) )`); err != nil { return err } @@ -496,6 +513,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE topics( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, state SMALLINT NOT NULL DEFAULT 0, @@ -513,8 +531,10 @@ func (a *adapter) CreateDb(reset bool) error { tags JSON, aux JSON, PRIMARY KEY(id), + UNIQUE INDEX topics_tenant_name(tenant_id, name), UNIQUE INDEX topics_name(name), INDEX topics_owner(owner), + INDEX topics_tenant_owner(tenant_id, owner), INDEX topics_state_stateat(state, stateat), INDEX topics_name_state_seqid(name, state, seqid) )`); err != nil { @@ -530,11 +550,13 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE topictags( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, topic CHAR(25) NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), INDEX topictags_tag(tag), + INDEX topictags_tenant_tag(tenant_id, tag), UNIQUE INDEX topictags_topic_tag(topic, tag) )`); err != nil { return err @@ -544,6 +566,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE subscriptions( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -557,6 +580,8 @@ func (a *adapter) CreateDb(reset bool) error { private JSON, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), + INDEX subscriptions_tenant_userid(tenant_id, userid), + INDEX subscriptions_tenant_topic(tenant_id, topic), UNIQUE INDEX subscriptions_topic_userid(topic, userid), INDEX subscriptions_topic(topic), INDEX subscriptions_deletedat(deletedat), @@ -569,6 +594,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE messages( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -576,19 +602,46 @@ func (a *adapter) CreateDb(reset bool) error { seqid INT NOT NULL, topic CHAR(25) NOT NULL,` + "`from` BIGINT NOT NULL," + - `head JSON, + `client_msg_id VARCHAR(64), + version INT NOT NULL DEFAULT 1, + recalled_at DATETIME(3), + recalled_by BIGINT, + head JSON, content JSON, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), + INDEX messages_tenant_topic(tenant_id, topic), + UNIQUE INDEX messages_sender_client_mid(tenant_id, ` + "`from`" + `, client_msg_id), UNIQUE INDEX messages_topic_seqid(topic, seqid) )`); err != nil { return err } + if _, err = tx.Exec( + `CREATE TABLE im_message_revisions( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + topic CHAR(25) NOT NULL, + seqid INT NOT NULL, + version INT NOT NULL, + actor BIGINT NOT NULL, + head JSON, + content JSON, + reason VARCHAR(256) NOT NULL DEFAULT '', + PRIMARY KEY(id), + UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), + CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`); err != nil { + return err + } + // Deletion log if _, err = tx.Exec( `CREATE TABLE dellog( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, topic CHAR(25) NOT NULL, deletedfor BIGINT NOT NULL DEFAULT 0, delid INT NOT NULL, @@ -596,6 +649,7 @@ func (a *adapter) CreateDb(reset bool) error { hi INT NOT NULL, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), + INDEX dellog_tenant_topic(tenant_id, topic), INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor), INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi), INDEX dellog_deletedfor(deletedfor) @@ -607,6 +661,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE credentials( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -619,7 +674,7 @@ func (a *adapter) CreateDb(reset bool) error { retries INT NOT NULL DEFAULT 0, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE credentials_uniqueness(synthetic) + UNIQUE credentials_tenant_uniqueness(tenant_id, synthetic) )`); err != nil { return err } @@ -629,6 +684,7 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE fileuploads( id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, userid BIGINT, @@ -638,6 +694,7 @@ func (a *adapter) CreateDb(reset bool) error { etag VARCHAR(128), location VARCHAR(2048) NOT NULL, PRIMARY KEY(id), + INDEX fileuploads_tenant_status(tenant_id, status), INDEX fileuploads_status(status) )`); err != nil { return err @@ -647,12 +704,14 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE filemsglinks( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, fileid BIGINT NOT NULL, msgid INT, topic CHAR(25), userid BIGINT, PRIMARY KEY(id), + INDEX filemsglinks_tenant_fileid(tenant_id, fileid), FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE, FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE, FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE, @@ -984,6 +1043,7 @@ func (a *adapter) UpgradeDb() error { tenant_id BIGINT NOT NULL, challenge_id VARCHAR(64) NOT NULL, scene VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL DEFAULT '', phone_hash CHAR(64) NOT NULL, phone_mask VARCHAR(32) NOT NULL, target_user BIGINT, @@ -1031,6 +1091,64 @@ func (a *adapter) UpgradeDb() error { } } + if a.version == 118 { + if _, err := a.db.Exec("ALTER TABLE im_verification_codes ADD COLUMN action VARCHAR(64) NOT NULL DEFAULT '' AFTER scene"); err != nil { + return err + } + tx, err := a.db.Begin() + if err != nil { + return err + } + if err = createIMSecurityTables(tx); err != nil { + tx.Rollback() + return err + } + if err = tx.Commit(); err != nil { + return err + } + if err := bumpVersion(a, 119); err != nil { + return err + } + } + + if a.version == 119 { + tx, err := a.db.Begin() + if err != nil { + return err + } + if err = createIMNotificationTables(tx); err != nil { + tx.Rollback() + return err + } + if err = tx.Commit(); err != nil { + return err + } + if err := bumpVersion(a, 120); err != nil { + return err + } + } + + if a.version == 120 { + if _, err := a.db.Exec("ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`"); err != nil { + return err + } + if _, err := a.db.Exec("CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id)"); err != nil { + return err + } + if err := bumpVersion(a, 121); err != nil { + return err + } + } + + if a.version == 121 { + if err := upgradeIMMessageActions(a); err != nil { + return err + } + if err := bumpVersion(a, 122); err != nil { + return err + } + } + if a.version != adpVersion { return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) + ". DB is still at " + strconv.Itoa(a.version)) @@ -1038,6 +1156,121 @@ func (a *adapter) UpgradeDb() error { return nil } +func createIMNotificationTables(tx *sql.Tx) error { + _, err := tx.Exec( + `CREATE TABLE im_system_notifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL, + action JSON, + read_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id), + INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC), + CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`) + return err +} + +func upgradeIMMessageActions(a *adapter) error { + statements := []string{ + "ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id", + "ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version", + "ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at", + `CREATE TABLE im_message_revisions( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + topic CHAR(25) NOT NULL, + seqid INT NOT NULL, + version INT NOT NULL, + actor BIGINT NOT NULL, + head JSON, + content JSON, + reason VARCHAR(256) NOT NULL DEFAULT '', + PRIMARY KEY(id), + UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), + CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`, + } + for _, stmt := range statements { + if _, err := a.db.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func createIMSecurityTables(tx *sql.Tx) error { + if _, err := tx.Exec( + `CREATE TABLE im_realname_verifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + submission_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + name_cipher TEXT NOT NULL, + id_type VARCHAR(32) NOT NULL, + id_number_cipher TEXT NOT NULL, + id_number_hash CHAR(64) NOT NULL, + masked_name VARCHAR(64) NOT NULL, + masked_id_number VARCHAR(64) NOT NULL, + review_reason VARCHAR(256), + verified_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id), + INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at), + INDEX idx_im_realname_id_hash(tenant_id, id_number_hash), + CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`); err != nil { + return err + } + if _, err := tx.Exec( + `CREATE TABLE im_action_tokens( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + token_id VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(128) NOT NULL DEFAULT '', + token_hash CHAR(64) NOT NULL, + state SMALLINT NOT NULL, + expires_at DATETIME(3) NOT NULL, + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id), + INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at), + CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`); err != nil { + return err + } + _, err := tx.Exec( + `CREATE TABLE im_youth_mode_settings( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + purpose VARCHAR(64) NOT NULL, + password_hash VARBINARY(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, user_id, purpose), + CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`) + return err +} + // Create system topic 'sys'. func createSystemTopic(tx *sql.Tx) error { now := t.TimeNow() @@ -1117,9 +1350,12 @@ func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) e // UserCreate creates a new user. Returns error and true if error is due to duplicate user name, // false for any other error func (a *adapter) UserCreate(user *t.User) error { - if user == nil || user.TenantID.IsZero() { + if user == nil { return t.ErrMalformed } + if user.TenantID.IsZero() { + user.TenantID = defaultTenantID + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -1955,9 +2191,13 @@ func (a *adapter) TopicCreate(topic *t.Topic) error { // If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate. func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error { - if sub == nil || sub.TenantID.IsZero() { + if sub == nil { return t.ErrMalformed } + tenantID := sub.TenantID + if tenantID.IsZero() { + tenantID = defaultTenantID + } isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner() @@ -1966,26 +2206,26 @@ func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error { _, err := tx.Exec( "INSERT INTO subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ "VALUES(?,?,?,NULL,?,?,?,?,?)", - sub.TenantID, sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) + tenantID, sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) if err != nil && isDupe(err) { if undelete { _, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+ "delid=0,recvseqid=0,readseqid=0 WHERE tenant_id=? AND topic=? AND userid=?", - sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.TenantID, + sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), tenantID, sub.Topic, decoded_uid) } else { _, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+ "delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND topic=? AND userid=?", sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, - sub.TenantID, sub.Topic, decoded_uid) + tenantID, sub.Topic, decoded_uid) } } if err == nil && isOwner { // Update topic owner if the subscription is with owner rights. // Don't increment subscriber count here - it's done in TopicShare in bulk. - _, err = tx.Exec("UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decoded_uid, sub.TenantID, sub.Topic) + _, err = tx.Exec("UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decoded_uid, tenantID, sub.Topic) } return err } diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql index 3914df1..aa34d78 100644 --- a/server/db/mysql/schema.sql +++ b/server/db/mysql/schema.sql @@ -50,6 +50,7 @@ CREATE TABLE im_verification_codes( tenant_id BIGINT NOT NULL, challenge_id VARCHAR(64) NOT NULL, scene VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL DEFAULT '', phone_hash CHAR(64) NOT NULL, phone_mask VARCHAR(32) NOT NULL, target_user BIGINT, @@ -83,10 +84,11 @@ CREATE TABLE kvmeta( INDEX kvmeta_createdat_key(createdat, `key`) ); -INSERT INTO kvmeta(`key`, `value`) VALUES("version", "118"); +INSERT INTO kvmeta(`key`, `value`) VALUES("version", "122"); CREATE TABLE users( id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, state SMALLINT NOT NULL DEFAULT 0, @@ -95,9 +97,11 @@ CREATE TABLE users( lastseen DATETIME, useragent VARCHAR(255) DEFAULT '', public JSON, + trusted JSON, tags JSON, -- Denormalized array of tags PRIMARY KEY(id), + INDEX users_tenant_id_id(tenant_id, id), INDEX users_state_stateat(state, stateat), INDEX users_lastseen_updatedat(lastseen, updatedat) ); @@ -114,21 +118,101 @@ CREATE TABLE im_user_security( CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ); +CREATE TABLE im_realname_verifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + submission_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + name_cipher TEXT NOT NULL, + id_type VARCHAR(32) NOT NULL, + id_number_cipher TEXT NOT NULL, + id_number_hash CHAR(64) NOT NULL, + masked_name VARCHAR(64) NOT NULL, + masked_id_number VARCHAR(64) NOT NULL, + review_reason VARCHAR(256), + verified_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + PRIMARY KEY(id), + UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id), + INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at), + INDEX idx_im_realname_id_hash(tenant_id, id_number_hash), + CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +); + +CREATE TABLE im_action_tokens( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + token_id VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(128) NOT NULL DEFAULT '', + token_hash CHAR(64) NOT NULL, + state SMALLINT NOT NULL, + expires_at DATETIME(3) NOT NULL, + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + PRIMARY KEY(id), + UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id), + INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at), + CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +); + +CREATE TABLE im_youth_mode_settings( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + purpose VARCHAR(64) NOT NULL, + password_hash VARBINARY(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + PRIMARY KEY(tenant_id, user_id, purpose), + CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +); + +CREATE TABLE im_system_notifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL, + action JSON, + read_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + PRIMARY KEY(id), + UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id), + INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC), + CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +); + # Indexed user tags. CREATE TABLE usertags( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, userid BIGINT NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), INDEX usertags_tag(tag), + INDEX usertags_tenant_tag(tenant_id, tag), UNIQUE INDEX usertags_userid_tag(userid, tag) ); # Indexed devices. Normalized into a separate table. CREATE TABLE devices( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, userid BIGINT NOT NULL, hash CHAR(16) NOT NULL, deviceid TEXT NOT NULL, @@ -138,12 +222,14 @@ CREATE TABLE devices( PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), + INDEX devices_tenant_userid(tenant_id, userid), UNIQUE INDEX devices_hash(hash) ); # Authentication records for the basic authentication scheme. CREATE TABLE auth( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, uname VARCHAR(32) NOT NULL, userid BIGINT NOT NULL, scheme VARCHAR(16) NOT NULL, @@ -153,14 +239,15 @@ CREATE TABLE auth( PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE INDEX auth_userid_scheme(userid, scheme), - UNIQUE INDEX auth_uname (uname) + UNIQUE INDEX auth_tenant_uname(tenant_id, uname), + UNIQUE INDEX auth_userid_scheme(userid, scheme) ); # Topics CREATE TABLE topics( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, touchedat DATETIME(3), @@ -179,27 +266,32 @@ CREATE TABLE topics( aux JSON, PRIMARY KEY(id), + UNIQUE INDEX topics_tenant_name(tenant_id, name), UNIQUE INDEX topics_name (name), INDEX topics_owner(owner), + INDEX topics_tenant_owner(tenant_id, owner), INDEX topics_state_stateat(state, stateat), - INDEX topics_name_state_seqid ON topics(name, state, seqid) + INDEX topics_name_state_seqid(name, state, seqid) ); # Indexed topic tags. CREATE TABLE topictags( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, topic CHAR(25) NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), INDEX topictags_tag (tag), + INDEX topictags_tenant_tag(tenant_id, tag), UNIQUE INDEX topictags_topic_tag(topic, tag) ); # Subscriptions CREATE TABLE subscriptions( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -214,15 +306,18 @@ CREATE TABLE subscriptions( PRIMARY KEY(id) , FOREIGN KEY(userid) REFERENCES users(id), + INDEX subscriptions_tenant_userid(tenant_id, userid), + INDEX subscriptions_tenant_topic(tenant_id, topic), UNIQUE INDEX subscriptions_topic_userid(topic, userid), INDEX subscriptions_topic(topic), INDEX subscriptions_deletedat(deletedat), - INDEX subscriptions_user_topic_deletedat ON subscriptions(userid, topic, deletedat) + INDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat) ); # Messages CREATE TABLE messages( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -230,17 +325,41 @@ CREATE TABLE messages( seqid INT NOT NULL, topic CHAR(25) NOT NULL, `from` BIGINT NOT NULL, + client_msg_id VARCHAR(64), + version INT NOT NULL DEFAULT 1, + recalled_at DATETIME(3), + recalled_by BIGINT, head JSON, content JSON, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), + INDEX messages_tenant_topic(tenant_id, topic), + UNIQUE INDEX messages_sender_client_mid(tenant_id, `from`, client_msg_id), UNIQUE INDEX messages_topic_seqid (topic, seqid) ); +CREATE TABLE im_message_revisions( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + topic CHAR(25) NOT NULL, + seqid INT NOT NULL, + version INT NOT NULL, + actor BIGINT NOT NULL, + head JSON, + content JSON, + reason VARCHAR(256) NOT NULL DEFAULT '', + PRIMARY KEY(id), + UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), + CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +); + # Deletion log CREATE TABLE dellog( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, topic CHAR(25) NOT NULL, deletedfor BIGINT NOT NULL DEFAULT 0, delid INT NOT NULL, @@ -249,6 +368,7 @@ CREATE TABLE dellog( PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), + INDEX dellog_tenant_topic(tenant_id, topic), # For getting the list of deleted message ranges INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor), # Used when getting not-yet-deleted messages(messages LEFT JOIN dellog) @@ -260,6 +380,7 @@ CREATE TABLE dellog( # User credentials CREATE TABLE credentials( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -272,13 +393,14 @@ CREATE TABLE credentials( retries INT NOT NULL DEFAULT 0, PRIMARY KEY(id), - UNIQUE credentials_uniqueness(synthetic), FOREIGN KEY(userid) REFERENCES users(id), + UNIQUE credentials_tenant_uniqueness(tenant_id, synthetic) ); # Records of uploaded files. Files themselves are stored elsewhere. CREATE TABLE fileuploads( id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, userid BIGINT, @@ -289,12 +411,14 @@ CREATE TABLE fileuploads( etag VARCHAR(128), PRIMARY KEY(id), + INDEX fileuploads_tenant_status(tenant_id, status), INDEX fileuploads_status(status) ); # Links between uploaded files and messages or topics. CREATE TABLE filemsglinks( id INT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, fileid BIGINT NOT NULL, msgid INT, @@ -302,8 +426,9 @@ CREATE TABLE filemsglinks( userid BIGINT, PRIMARY KEY(id), + INDEX filemsglinks_tenant_fileid(tenant_id, fileid), FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE, FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY(topicid) REFERENCES topics(id) ON DELETE CASCADE, + FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE, FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE ); diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index d94858e..d64cbc4 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -4,7 +4,10 @@ package mysql import ( + "context" + "crypto/sha256" "database/sql" + "fmt" "sort" "strconv" "strings" @@ -1344,9 +1347,10 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { if cancel != nil { defer cancel() } + clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} res, err := a.db.ExecContext(ctx, - "INSERT INTO messages(tenant_id,createdat,updatedat,seqid,topic,`from`,head,content) VALUES(?,?,?,?,?,?,?,?)", - id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content)) + "INSERT INTO messages(tenant_id,createdat,updatedat,seqid,topic,`from`,client_msg_id,version,head,content) VALUES(?,?,?,?,?,?,?,?,?,?)", + id, msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content)) if err == nil { lastID, _ := res.LastInsertId() msg.SetUid(t.Uid(lastID)) @@ -1354,6 +1358,128 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { return err } +func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) { + if err := validTenant(id); err != nil || from.IsZero() || clientMsgID == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var msg t.Message + err := a.db.QueryRowxContext(ctx, + "SELECT id,tenant_id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ + "FROM messages WHERE tenant_id=? AND `from`=? AND client_msg_id=?", + id, decodeUID(from), clientMsgID).StructScan(&msg) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + msg.From = common.EncodeUidString(msg.From).String() + msg.Content = common.FromJSON(msg.Content) + return &msg, nil +} + +func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.Message, error) { + if err := validTenant(id); err != nil || topic == "" || seq <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + msg, err := tenantMessageGetOne(a.db, ctx, id, topic, seq, "") + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return msg, err +} + +func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) { + if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE") + if err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil { + return nil, t.ErrDuplicate + } + _, err = tx.ExecContext(ctx, + "UPDATE messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", + recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return a.TenantMessageGetOne(id, topic, seq) +} + +func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) { + if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + msg, err := tenantMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE") + if err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil { + return nil, t.ErrDuplicate + } + if _, err = tx.ExecContext(ctx, + "INSERT INTO im_message_revisions(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?,?)", + id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil { + return nil, err + } + _, err = tx.ExecContext(ctx, + "UPDATE messages SET updatedat=?,version=version+1,head=?,content=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", + editedAt, head, common.ToJSON(content), id, topic, seq, expectedVersion) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return a.TenantMessageGetOne(id, topic, seq) +} + func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) { if err := validTenant(id); err != nil || topic == "" { return nil, t.ErrMalformed @@ -1390,7 +1516,7 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op defer cancel() } rows, err := a.db.QueryxContext(ctx, - "SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content "+ + "SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ "FROM messages AS m LEFT JOIN dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+ "AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE m.tenant_id=? AND m.delid=0 AND m.topic=? "+ seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", @@ -1412,6 +1538,34 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op return msgs, rows.Err() } +func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) { + row := struct { + t.Message + RecalledBy sql.NullInt64 `db:"recalled_by"` + }{} + err := sqlx.GetContext(ctx, q, &row, + "SELECT id,tenant_id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ + "FROM messages WHERE tenant_id=? AND topic=? AND seqid=?"+suffix, + tenantID, topic, seq) + if err != nil { + return nil, err + } + msg := row.Message + msg.From = common.EncodeUidString(msg.From).String() + if row.RecalledBy.Valid { + msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(row.RecalledBy.Int64, 10)).String() + } + msg.Content = common.FromJSON(msg.Content) + return &msg, nil +} + +func messageVersion(version int) int { + if version <= 0 { + return 1 + } + return version +} + func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error { if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) { return t.ErrMalformed @@ -1867,10 +2021,10 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode vc.UpdatedAt = now _, err = tx.ExecContext(ctx, `INSERT INTO im_verification_codes - (tenant_id,challenge_id,scene,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts, + (tenant_id,challenge_id,scene,action,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts, expires_at,resend_after,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - id, vc.ChallengeID, vc.Scene, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts, + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, vc.ChallengeID, vc.Scene, vc.Action, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts, vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt) if isDupe(err) { return t.ErrDuplicate @@ -1891,7 +2045,7 @@ func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.V } var vc t.VerificationCode err := a.db.GetContext(ctx, &vc, - `SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, + `SELECT id,tenant_id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at FROM im_verification_codes WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID) @@ -1914,7 +2068,7 @@ func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash st } var vc t.VerificationCode err := a.db.GetContext(ctx, &vc, - `SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, + `SELECT id,tenant_id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at FROM im_verification_codes WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>? @@ -2058,9 +2212,327 @@ func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, up return version, nil } +func (a *adapter) TenantRealNameGet(id t.TenantID, uid t.Uid) (*t.RealNameVerification, error) { + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var rv t.RealNameVerification + err := a.db.GetContext(ctx, &rv, + `SELECT id,tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash, + masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at + FROM im_realname_verifications + WHERE tenant_id=? AND user_id=? + ORDER BY created_at DESC,id DESC LIMIT 1`, + id, decodeUID(uid)) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + return &rv, nil +} + +func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification) error { + if err := validTenant(id); err != nil || rv == nil || rv.TenantID != id || rv.UserID.IsZero() || + rv.SubmissionID == "" || rv.Status == "" || rv.NameCipher == "" || rv.IDType == "" || + rv.IDNumberCipher == "" || rv.IDNumberHash == "" || rv.MaskedName == "" || rv.MaskedIDNumber == "" { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + now := t.TimeNow() + rv.CreatedAt = now + rv.UpdatedAt = now + _, err := a.db.ExecContext(ctx, + `INSERT INTO im_realname_verifications + (tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash, + masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, decodeUID(rv.UserID), rv.SubmissionID, rv.Status, rv.NameCipher, rv.IDType, rv.IDNumberCipher, rv.IDNumberHash, + rv.MaskedName, rv.MaskedIDNumber, rv.ReviewReason, rv.VerifiedAt, rv.CreatedAt, rv.UpdatedAt) + if isDupe(err) { + return t.ErrDuplicate + } + return err +} + +func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error { + if err := validTenant(id); err != nil || token == nil || token.TenantID != id || token.UserID.IsZero() || + token.TokenID == "" || token.Action == "" || token.TokenHash == "" || token.ExpiresAt.IsZero() { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + now := t.TimeNow() + token.CreatedAt = now + token.UpdatedAt = now + _, err := a.db.ExecContext(ctx, + `INSERT INTO im_action_tokens + (tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`, + id, decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State, + token.ExpiresAt, token.CreatedAt, token.UpdatedAt) + if isDupe(err) { + return t.ErrDuplicate + } + return err +} + +func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, action, target string, now time.Time) (*t.ActionToken, error) { + if err := validTenant(id); err != nil || uid.IsZero() || token == "" || action == "" { + return nil, t.ErrMalformed + } + if now.IsZero() { + now = t.TimeNow() + } + tokenID, secret, ok := strings.Cut(token, ".") + if !ok || tokenID == "" || secret == "" { + return nil, t.ErrMalformed + } + tokenHash := securityTokenHash(id, uid, action, target, tokenID, secret) + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + var at t.ActionToken + err = tx.GetContext(ctx, &at, + `SELECT id,tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at + FROM im_action_tokens + WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`, + id, decodeUID(uid), tokenID, action, target) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + if at.State != 0 || at.TokenHash != tokenHash || !now.Before(at.ExpiresAt) { + return nil, t.ErrNotFound + } + res, err := tx.ExecContext(ctx, + `UPDATE im_action_tokens SET state=1,consumed_at=?,updated_at=? + WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`, + now, now, id, decodeUID(uid), tokenID, action, target, now) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, t.ErrNotFound + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &at, nil +} + +func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose string) (*t.IndependentPassword, error) { + if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var pass t.IndependentPassword + err := a.db.GetContext(ctx, &pass, + `SELECT tenant_id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at + FROM im_youth_mode_settings + WHERE tenant_id=? AND user_id=? AND purpose=?`, + id, decodeUID(uid), purpose) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if err != nil { + return nil, err + } + return &pass, nil +} + +func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.IndependentPassword) error { + if err := validTenant(id); err != nil || pass == nil || pass.TenantID != id || pass.UserID.IsZero() || + pass.Purpose == "" || len(pass.PasswordHash) == 0 { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + now := t.TimeNow() + pass.CreatedAt = now + pass.UpdatedAt = now + _, err := a.db.ExecContext(ctx, + `INSERT INTO im_youth_mode_settings + (tenant_id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at) + VALUES(?,?,?,?,0,NULL,?,?) + ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`, + id, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt) + return err +} + +func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error { + if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" || attempts < 0 { + return t.ErrMalformed + } + if updatedAt.IsZero() { + updatedAt = t.TimeNow() + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + _, err := a.db.ExecContext(ctx, + `UPDATE im_youth_mode_settings SET failed_attempts=?,locked_until=?,updated_at=? + WHERE tenant_id=? AND user_id=? AND purpose=?`, + attempts, lockedUntil, updatedAt, id, decodeUID(uid), purpose) + return err +} + +func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.SystemNotification) error { + if err := validTenant(id); err != nil || notification == nil || notification.TenantID != id || + notification.UserID.IsZero() || notification.EventID == "" || notification.EventType == "" || + notification.Title == "" { + return t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + now := t.TimeNow() + notification.CreatedAt = now + notification.UpdatedAt = now + _, err := a.db.ExecContext(ctx, + `INSERT INTO im_system_notifications + (tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE + event_type=VALUES(event_type), + title=VALUES(title), + summary=VALUES(summary), + action=VALUES(action), + updated_at=VALUES(updated_at)`, + id, decodeUID(notification.UserID), notification.EventID, notification.EventType, notification.Title, + notification.Summary, common.ToJSON(notification.Action), notification.ReadAt, notification.CreatedAt, notification.UpdatedAt) + return err +} + +func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.SystemNotification, string, error) { + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 20 + } + lastID := int64(0) + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed < 0 { + return nil, "", t.ErrMalformed + } + lastID = parsed + } + query := `SELECT id,tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at + FROM im_system_notifications + WHERE tenant_id=? AND user_id=?` + args := []any{id, decodeUID(uid)} + if lastID > 0 { + query += " AND id 64 || strings.TrimSpace(body.Channel) != "sms" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + phone, err := currentUserPhone(principal.TenantID, principal.Uid) + if err != nil { + writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil) + return + } + createSensitiveChallenge(wrt, req, principal, action, phone) +} + +func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + challengeID := securityChallengeIDFromPath(req.URL.Path) + if challengeID == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + var body imVerificationVerifyRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action") + if !ok { + return + } + if vc.TargetUser != principal.Uid { + writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) + return + } + token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID)) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "action_token": token, + "action": vc.Action, + "expires_at": expiresAt.Format(time.RFC3339Nano), + }) +} + +func serveIMRealName(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid) + if err != nil { + if errors.Is(err, types.ErrNotFound) { + writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true}) + return + } + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, realNamePayload(rv)) +} + +func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + if idempotencyKey == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"}) + return + } + var body imRealNameSubmissionRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + name := strings.TrimSpace(body.Name) + idType := strings.TrimSpace(body.IDType) + idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber)) + if !validIMRealName(name, idType, idNumber) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + current, err := store.Security.GetRealName(principal.TenantID, principal.Uid) + if err == nil && (current.Status == "pending" || current.Status == "verified") { + writeIMSuccess(wrt, req, realNamePayload(current)) + return + } + if err != nil && !errors.Is(err, types.ErrNotFound) { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32] + rv := &types.RealNameVerification{ + TenantID: principal.TenantID, + UserID: principal.Uid, + SubmissionID: submissionID, + Status: "pending", + NameCipher: protectIMSensitive(principal.TenantID, name), + IDType: idType, + IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber), + IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"), + MaskedName: maskIMName(name), + MaskedIDNumber: maskIMIDNumber(idNumber), + } + if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil { + if errors.Is(err, types.ErrDuplicate) { + writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false}) + return + } + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"}) +} + +func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + status := "unverified" + if rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid); err == nil { + status = rv.Status + } else if err != nil && !errors.Is(err, types.ErrNotFound) { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "features": map[string]any{ + "create_group": true, + "user_search": true, + "scan_qr": true, + "system_notifications": true, + "message_recall": false, + "message_edit": false, + }, + "permissions": map[string]any{ + "create_group": true, + "user_search": true, + "scan_qr": true, + }, + "system_topic": map[string]any{ + "topic": "sys", + "type": "system", + }, + "message_policy": map[string]any{ + "recall_window_seconds": 120, + "edit_window_seconds": 300, + "supported_actions": []string{"copy", "quote", "delete"}, + }, + "security": map[string]any{ + "realname": map[string]any{ + "status": status, + "required": false, + "allow_skip": true, + "remind_after": "24h", + }, + }, + }) +} + +func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q"))) + cursor := strings.TrimSpace(req.URL.Query().Get("cursor")) + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) + subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100}) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + start := 0 + if cursor != "" { + parsed, err := strconv.Atoi(cursor) + if err != nil || parsed < 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) + return + } + start = parsed + } + items := make([]map[string]any, 0, limit) + nextIndex := start + for i := start; i < len(subs); i++ { + if query != "" && !conversationMatches(subs[i], query) { + continue + } + items = append(items, conversationPayload(subs[i])) + nextIndex = i + 1 + if len(items) == limit { + break + } + } + nextCursor := "" + if nextIndex < len(subs) { + nextCursor = strconv.Itoa(nextIndex) + } + writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) +} + +func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) + items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) +} + +func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + eventID := systemNotificationIDFromPath(req.URL.Path) + if eventID == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"}) + return + } + if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"}) + return + } + var body imSystemNotificationReadRequest + if req.Body != nil && req.ContentLength != 0 { + if !decodeIMJSONBody(wrt, req, &body) { + return + } + } + readAt := time.Now().UTC().Round(time.Millisecond) + if body.ReadAt != "" { + parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"}) + return + } + readAt = parsed.UTC().Round(time.Millisecond) + } + if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil { + if errors.Is(err, types.ErrNotFound) { + writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil) + return + } + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)}) +} + +func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { + switch { + case strings.HasSuffix(req.URL.Path, "/receipts"): + serveIMMessageReceipts(wrt, req) + case strings.HasSuffix(req.URL.Path, "/recall"): + serveIMMessageRecall(wrt, req) + case strings.HasSuffix(req.URL.Path, "/revisions"): + serveIMMessageRevision(wrt, req) + default: + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil) + } +} + +func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + topic, seq, ok := messageReceiptsPath(req.URL.Path) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil) + return + } + if seq <= 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"}) + return + } + sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200) + cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor")) + members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1}) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + readItems := make([]map[string]any, 0, limit) + deliveredItems := make([]map[string]any, 0, limit) + nextCursor := 0 + for i, member := range members { + if i < cursor { + continue + } + if len(readItems) >= limit && len(deliveredItems) >= limit { + nextCursor = i + break + } + item := receiptMemberPayload(member) + if member.ReadSeqId >= seq && len(readItems) < limit { + readItems = append(readItems, item) + } + if member.RecvSeqId >= seq && len(deliveredItems) < limit { + deliveredItems = append(deliveredItems, item) + } + } + writeIMSuccess(wrt, req, map[string]any{ + "topic": topic, + "seq": seq, + "read": readItems, + "delivered": deliveredItems, + "next_cursor": nextCursor, + }) +} + +func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + topic, seq, ok := messageActionPath(req.URL.Path, "recall") + if !ok || seq <= 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + var body imMessageRecallRequest + if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 { + if body.Version <= 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"}) + } + return + } + msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true) + if err != nil { + writeIMMessageMutationError(wrt, req, err) + return + } + if !authorized { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil) + return + } + if time.Since(msg.CreatedAt) > 120*time.Second { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil) + return + } + recalledAt := time.Now().UTC().Round(time.Millisecond) + updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, body.Version, recalledAt) + if err != nil { + writeIMMessageMutationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled")) +} + +func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + topic, seq, ok := messageActionPath(req.URL.Path, "revisions") + if !ok || seq <= 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + var body imMessageRevisionRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if body.Version <= 0 || body.Content == nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"}) + return + } + msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false) + if err != nil { + writeIMMessageMutationError(wrt, req, err) + return + } + if !authorized { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil) + return + } + if time.Since(msg.CreatedAt) > 300*time.Second { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil) + return + } + if !isIMEditableTextMessage(msg) { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil) + return + } + head := body.Head + if head == nil { + head = types.KVMap{} + } + head["type"] = "text" + if body.ClientMid != "" { + head["x-im-client-mid"] = body.ClientMid + } + editedAt := time.Now().UTC().Round(time.Millisecond) + updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt) + if err != nil { + writeIMMessageMutationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited")) +} + +func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imIndependentPasswordRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + purpose := strings.TrimSpace(body.Purpose) + if purpose == "" { + purpose = "youth_mode" + } + if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" { + writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil) + return + } + if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil { + writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{ + TenantID: principal.TenantID, + UserID: principal.Uid, + Purpose: purpose, + PasswordHash: hash, + }); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose}) +} + +func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imIndependentPasswordVerifyRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + purpose := strings.TrimSpace(body.Purpose) + if purpose == "" { + purpose = "youth_mode" + } + action := strings.TrimSpace(body.Action) + if action == "" { + action = "close_youth_mode" + } + pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose) + if err != nil { + writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil) + return + } + now := time.Now().UTC().Round(time.Millisecond) + if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) { + writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{ + "retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1, + }) + return + } + if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil { + attempts := pass.FailedAttempts + 1 + var lockedUntil *time.Time + if attempts >= imIndependentPasswordMaxAttempts { + until := now.Add(imIndependentPasswordLockSeconds * time.Second) + lockedUntil = &until + } + _ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now) + if lockedUntil != nil { + writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{ + "retry_after_seconds": imIndependentPasswordLockSeconds, + }) + return + } + writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil) + return + } + _ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now) + token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "action_token": token, + "action": action, + "expires_at": expiresAt.Format(time.RFC3339Nano), + }) +} + func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) { if len(code) != imVerificationCodeLength { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"}) @@ -505,6 +1139,321 @@ func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID return vc, token, true } +func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) { + authz := strings.TrimSpace(req.Header.Get("Authorization")) + token, ok := strings.CutPrefix(authz, "Bearer ") + if !ok || strings.TrimSpace(token) == "" || store.Store == nil { + writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) + return nil, false + } + handler := store.Store.GetLogicalAuthHandler("token") + if handler == nil || !handler.IsInitialized() { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return nil, false + } + rec, _, err := handler.Authenticate(auth.AuthContext{}, []byte(strings.TrimSpace(token))) + if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() { + writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) + return nil, false + } + return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, true +} + +func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { + creds, err := store.Users.GetAllCreds(tenantID, uid, "tel", true) + if err != nil { + return "", err + } + if len(creds) == 0 || strings.TrimSpace(creds[0].Value) == "" { + return "", types.ErrNotFound + } + return strings.TrimSpace(creds[0].Value), nil +} + +func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { + now := time.Now().UTC().Round(time.Millisecond) + phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone") + active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now) + if err != nil && !errors.Is(err, types.ErrNotFound) { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if active != nil && now.Before(active.ResendAfter) { + writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ + "challenge_id": active.ChallengeID, + "retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1, + }) + return + } + challengeID, err := randomIMToken(18) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + code, err := randomIMNumericCode(imVerificationCodeLength) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + vc := &types.VerificationCode{ + TenantID: principal.TenantID, + ChallengeID: challengeID, + Scene: "sensitive_action", + Action: action, + PhoneHash: phoneHash, + PhoneMask: maskIMPhone(phone), + TargetUser: principal.Uid, + CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code), + State: types.VerificationStatePending, + MaxAttempts: imVerificationMaxAttempts, + ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second), + ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second), + } + if err := store.Verifications.Create(principal.TenantID, vc); err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + logs.Info.Println("im security verification code generated:", action, vc.PhoneMask) + writeIMSuccess(wrt, req, map[string]any{ + "challenge_id": vc.ChallengeID, + "phone_mask": vc.PhoneMask, + "expires_in_seconds": imVerificationExpiresInSeconds, + "resend_after_seconds": imVerificationResendAfterSeconds, + }) +} + +func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) { + if action == "" { + return "", time.Time{}, types.ErrMalformed + } + tokenID, err := randomIMToken(12) + if err != nil { + return "", time.Time{}, err + } + secret, err := randomIMToken(32) + if err != nil { + return "", time.Time{}, err + } + expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second) + if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{ + TenantID: tenantID, + UserID: uid, + TokenID: tokenID, + Action: action, + Target: target, + TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret), + State: 0, + ExpiresAt: expiresAt, + }); err != nil { + return "", time.Time{}, err + } + return tokenID + "." + secret, expiresAt, nil +} + +func defaultIMActionTarget(action, fallback string) string { + switch action { + case "set_independent_password", "close_youth_mode", "reset_independent_password": + return "youth_mode" + default: + return fallback + } +} + +func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string { + h := sha256.New() + h.Write([]byte(fmt.Sprintf("%d", tenantID))) + h.Write([]byte{0}) + h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid)))) + 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)) +} + +func realNamePayload(rv *types.RealNameVerification) map[string]any { + canSubmit := rv.Status == "unverified" || rv.Status == "rejected" + data := map[string]any{ + "status": rv.Status, + "submission_id": rv.SubmissionID, + "masked_name": rv.MaskedName, + "masked_id_number": rv.MaskedIDNumber, + "can_submit": canSubmit, + } + if rv.VerifiedAt != nil { + data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano) + } + if rv.ReviewReason != nil { + data["review_reason"] = *rv.ReviewReason + } + return data +} + +func parseIMLimit(raw string, fallback, max int) int { + limit := fallback + if raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + limit = parsed + } + } + if limit > max { + limit = max + } + return limit +} + +func conversationMatches(sub types.Subscription, query string) bool { + if strings.Contains(strings.ToLower(sub.Topic), query) { + return true + } + encoded, _ := json.Marshal(sub.GetPublic()) + return strings.Contains(strings.ToLower(string(encoded)), query) +} + +func conversationPayload(sub types.Subscription) map[string]any { + unread := sub.GetSeqId() - sub.ReadSeqId + if unread < 0 { + unread = 0 + } + return map[string]any{ + "topic": sub.Topic, + "public": sub.GetPublic(), + "private": sub.Private, + "updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano), + "seq": sub.GetSeqId(), + "read": sub.ReadSeqId, + "recv": sub.RecvSeqId, + "unread": unread, + "with": sub.GetWith(), + } +} + +func receiptMemberPayload(sub types.Subscription) map[string]any { + return map[string]any{ + "user_id": sub.User, + "public": sub.GetPublic(), + "read": sub.ReadSeqId, + "recv": sub.RecvSeqId, + } +} + +func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) { + if principal == nil { + return nil, false, types.ErrPermissionDenied + } + sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) + if err != nil { + return nil, false, err + } + if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { + return nil, false, types.ErrPermissionDenied + } + msg, err := store.Messages.GetOne(principal.TenantID, topic, seq) + if err != nil { + return nil, false, err + } + if msg == nil { + return nil, false, types.ErrNotFound + } + if msg.RecalledAt != nil || msg.DeletedAt != nil { + return msg, false, types.ErrDuplicate + } + from := types.ParseUid(msg.From) + access := sub.ModeGiven & sub.ModeWant + authorized := from == principal.Uid || (allowAdmin && access.IsAdmin()) + return msg, authorized, nil +} + +func isIMEditableTextMessage(msg *types.Message) bool { + if msg == nil { + return false + } + if msg.Head == nil { + return true + } + msgType, _ := msg.Head["type"].(string) + return msgType == "" || msgType == "text" +} + +func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) { + switch { + case errors.Is(err, types.ErrNotFound): + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil) + case errors.Is(err, types.ErrDuplicate): + writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil) + case errors.Is(err, types.ErrPermissionDenied): + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) + case errors.Is(err, types.ErrMalformed): + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + default: + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + } +} + +func messageMutationPayload(msg *types.Message, status string) map[string]any { + data := map[string]any{ + "status": status, + "topic": msg.Topic, + "seq": msg.SeqId, + "version": msg.Version, + "updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if msg.RecalledAt != nil { + data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano) + data["recalled_by"] = msg.RecalledBy + } + if status == "edited" { + data["head"] = msg.Head + data["content"] = msg.Content + } + return data +} + +func validIMRealName(name, idType, idNumber string) bool { + if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" { + return false + } + return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber) +} + +func protectIMSensitive(tenantID types.TenantID, value string) string { + return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive"))) +} + +func maskIMName(name string) string { + runes := []rune(name) + if len(runes) <= 1 { + return name + } + return string(runes[0]) + "*" +} + +func maskIMIDNumber(id string) string { + if len(id) <= 8 { + return id + } + return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:] +} + +func validIMIndependentPassword(password string) bool { + if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) { + return false + } + if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] { + return false + } + ascending, descending := true, true + for i := 1; i < len(password); i++ { + ascending = ascending && password[i] == password[i-1]+1 + descending = descending && password[i]+1 == password[i-1] + } + return !ascending && !descending +} + func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool { if req.Body == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) @@ -669,3 +1618,75 @@ func passwordResetChallengeIDFromPath(path string) string { } return strings.TrimSpace(challengeID) } + +func securityChallengeIDFromPath(path string) string { + const marker = "/v1/im/security/challenges/" + idx := strings.Index(path, marker) + if idx < 0 { + return "" + } + rest := strings.TrimPrefix(path[idx:], marker) + challengeID, suffix, ok := strings.Cut(rest, "/") + if !ok || suffix != "verify" { + return "" + } + return strings.TrimSpace(challengeID) +} + +func systemNotificationIDFromPath(path string) string { + const marker = "/v1/im/system-notifications/" + idx := strings.Index(path, marker) + if idx < 0 { + return "" + } + rest := strings.TrimPrefix(path[idx:], marker) + eventID, suffix, ok := strings.Cut(rest, "/") + if !ok || suffix != "read" { + return "" + } + return strings.TrimSpace(eventID) +} + +func messageReceiptsPath(path string) (string, int, bool) { + const marker = "/v1/im/topics/" + idx := strings.Index(path, marker) + if idx < 0 { + return "", 0, false + } + rest := strings.TrimPrefix(path[idx:], marker) + topic, rest, ok := strings.Cut(rest, "/messages/") + if !ok || topic == "" { + return "", 0, false + } + seqValue, suffix, ok := strings.Cut(rest, "/") + if !ok || suffix != "receipts" { + return "", 0, false + } + seq, err := strconv.Atoi(seqValue) + if err != nil { + return topic, 0, true + } + return topic, seq, true +} + +func messageActionPath(path, action string) (string, int, bool) { + const marker = "/v1/im/topics/" + idx := strings.Index(path, marker) + if idx < 0 { + return "", 0, false + } + rest := strings.TrimPrefix(path[idx:], marker) + topic, rest, ok := strings.Cut(rest, "/messages/") + if !ok || topic == "" { + return "", 0, false + } + seqValue, suffix, ok := strings.Cut(rest, "/") + if !ok || suffix != action { + return "", 0, false + } + seq, err := strconv.Atoi(seqValue) + if err != nil { + return topic, 0, true + } + return topic, seq, true +} diff --git a/server/im_http_test.go b/server/im_http_test.go index d9fe1b9..bce490d 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -13,8 +13,13 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/auth/mock_auth" "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/mock_store" "github.com/tinode/chat/server/store/types" + "golang.org/x/crypto/bcrypt" ) type testVerificationStore struct { @@ -94,6 +99,20 @@ func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPRe return resp } +func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantID types.TenantID, uid types.Uid) *mock_store.MockPersistentStorageInterface { + t.Helper() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) + tokenAuth.EXPECT().IsInitialized().Return(true) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)). + Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil) + store.Store = mockStore + t.Cleanup(func() { store.Store = oldStore }) + return mockStore +} + func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ @@ -425,6 +444,427 @@ func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) { } } +func TestServeIMRealNameReturnsUnverified(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSecurity := store.Security + store.Security = &testSecurityStore{} + defer func() { store.Security = oldSecurity }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/security/realname", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMRealName(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != "unverified" || data["can_submit"] != true { + t.Fatalf("unexpected realname payload: %#v", data) + } +} + +func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSecurity := store.Security + security := &testSecurityStore{} + store.Security = security + defer func() { store.Security = oldSecurity }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions", + bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "idem-1") + rec := httptest.NewRecorder() + + serveIMRealNameSubmissions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if security.realName == nil || security.realName.Status != "pending" || security.realName.NameCipher == "张三" { + t.Fatalf("unexpected stored realname: %#v", security.realName) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != "pending" || data["submission_id"] == "" { + t.Fatalf("unexpected response: %#v", data) + } +} + +func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldSecurity := store.Security + security := &testSecurityStore{actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}} + store.Security = security + defer func() { store.Security = oldSecurity }() + + expectIMBearer(t, ctrl, "token-set", 7, 42) + req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password", + bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action_token":"action.1"}`)) + req.Header.Set("Authorization", "Bearer token-set") + rec := httptest.NewRecorder() + + serveIMIndependentPassword(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("set status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if security.independentPassword == nil || + bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil { + t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword) + } + + expectIMBearer(t, ctrl, "token-verify", 7, 42) + req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify", + bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action":"close_youth_mode"}`)) + req.Header.Set("Authorization", "Bearer token-verify") + rec = httptest.NewRecorder() + + serveIMIndependentPasswordVerify(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["action"] != "close_youth_mode" || data["action_token"] == "" { + t.Fatalf("unexpected verify response: %#v", data) + } +} + +func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldVerifications := store.Verifications + oldSecurity := store.Security + security := &testSecurityStore{} + store.Security = security + store.Verifications = &testVerificationStore{get: &types.VerificationCode{ + TenantID: 7, + ChallengeID: "challenge-1", + Scene: "sensitive_action", + Action: "set_independent_password", + PhoneHash: "phone-hash", + TargetUser: 42, + CodeHash: hashIMSecret(7, "sensitive_action", "challenge-1", "phone-hash", "123456"), + State: types.VerificationStatePending, + MaxAttempts: 5, + ExpiresAt: time.Now().UTC().Add(time.Minute), + }} + defer func() { + store.Verifications = oldVerifications + store.Security = oldSecurity + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/challenge-1/verify", + bytes.NewBufferString(`{"code":"123456"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMSecurityChallengeVerify(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if security.actionToken == nil || security.actionToken.Action != "set_independent_password" || security.actionToken.Target != "youth_mode" { + t.Fatalf("unexpected action token: %#v", security.actionToken) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["action"] != "set_independent_password" || data["action_token"] == "" { + t.Fatalf("unexpected response: %#v", data) + } +} + +func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSecurity := store.Security + store.Security = &testSecurityStore{} + defer func() { store.Security = oldSecurity }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMBootstrap(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil { + t.Fatalf("bootstrap missing home capabilities: %#v", data) + } +} + +func TestServeIMConversationSearch(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldUsers := store.Users + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + store.Users = users + defer func() { store.Users = oldUsers }() + now := time.Now().UTC().Round(time.Millisecond) + alice := types.Subscription{ + ObjHeader: types.ObjHeader{UpdatedAt: now}, + Topic: "p2pAlice", + ReadSeqId: 2, + RecvSeqId: 3, + } + alice.SetPublic(map[string]any{"fn": "Alice"}) + alice.SetSeqId(5) + alice.SetTouchedAt(now) + bob := types.Subscription{ + ObjHeader: types.ObjHeader{UpdatedAt: now}, + Topic: "grpBob", + ReadSeqId: 9, + } + bob.SetPublic(map[string]any{"fn": "Bob Group"}) + bob.SetSeqId(9) + bob.SetTouchedAt(now) + users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{alice, bob}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMConversationSearch(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("items length: got %d, want 1; data=%#v", len(items), data) + } + item := items[0].(map[string]any) + if item["topic"] != "p2pAlice" || item["unread"].(float64) != 3 { + t.Fatalf("unexpected conversation item: %#v", item) + } +} + +func TestServeIMSystemNotificationsListAndRead(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldSecurity := store.Security + security := &testSecurityStore{notifications: []types.SystemNotification{{ + TenantID: 7, + UserID: 42, + EventID: "evt-1", + EventType: "security.new_device_login", + Title: "新设备登录", + Summary: "iPhone 登录", + Action: types.KVMap{"type": "security_center"}, + CreatedAt: time.Now().UTC().Round(time.Millisecond), + }}} + store.Security = security + defer func() { store.Security = oldSecurity }() + + expectIMBearer(t, ctrl, "token-list", 7, 42) + req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications", nil) + req.Header.Set("Authorization", "Bearer token-list") + rec := httptest.NewRecorder() + + serveIMSystemNotifications(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if len(data["items"].([]any)) != 1 { + t.Fatalf("unexpected list response: %#v", data) + } + + expectIMBearer(t, ctrl, "token-read", 7, 42) + req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{}`)) + req.Header.Set("Authorization", "Bearer token-read") + req.Header.Set("Idempotency-Key", "read-1") + rec = httptest.NewRecorder() + + serveIMSystemNotificationRead(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil { + t.Fatalf("notification was not marked read: %#v", security) + } +} + +func TestServeIMMessageReceipts(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldTopics := store.Topics + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + store.Subs = subs + store.Topics = topics + defer func() { + store.Subs = oldSubs + store.Topics = oldTopics + }() + + mySub := &types.Subscription{ + TenantID: 7, + User: "42", + Topic: "grpReceipt", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + } + subs.EXPECT().Get(types.TenantID(7), "grpReceipt", types.Uid(42), false).Return(mySub, nil) + readMember := types.Subscription{User: "101", ReadSeqId: 8, RecvSeqId: 8} + readMember.SetPublic(map[string]any{"fn": "Alice"}) + deliveredMember := types.Subscription{User: "102", ReadSeqId: 4, RecvSeqId: 8} + deliveredMember.SetPublic(map[string]any{"fn": "Bob"}) + topics.EXPECT().GetUsers(types.TenantID(7), "grpReceipt", gomock.Any()).Return([]types.Subscription{readMember, deliveredMember}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpReceipt/messages/8/receipts", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMMessageReceipts(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["topic"] != "grpReceipt" || data["seq"].(float64) != 8 { + t.Fatalf("unexpected receipt envelope: %#v", data) + } + if len(data["read"].([]any)) != 1 || len(data["delivered"].([]any)) != 2 { + t.Fatalf("unexpected receipt items: %#v", data) + } +} + +func TestServeIMMessageRecall(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldMessages := store.Messages + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages + defer func() { + store.Subs = oldSubs + store.Messages = oldMessages + }() + + subs.EXPECT().Get(types.TenantID(7), "grpRecall", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: "42", + Topic: "grpRecall", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + }, nil) + messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{ + TenantID: 7, + SeqId: 9, + Topic: "grpRecall", + From: types.Uid(42).String(), + Version: 1, + ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, + }, nil) + messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{ + TenantID: 7, + SeqId: 9, + Topic: "grpRecall", + From: "42", + Version: 2, + RecalledAt: ptrTime(time.Now().UTC()), + RecalledBy: "42", + ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{"version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMMessageRecall(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != "recalled" || data["version"].(float64) != 2 { + t.Fatalf("unexpected recall response: %#v", data) + } +} + +func TestServeIMMessageRevision(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldMessages := store.Messages + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages + defer func() { + store.Subs = oldSubs + store.Messages = oldMessages + }() + + subs.EXPECT().Get(types.TenantID(7), "grpEdit", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: "42", + Topic: "grpEdit", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + }, nil) + messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{ + TenantID: 7, + SeqId: 3, + Topic: "grpEdit", + From: types.Uid(42).String(), + Version: 1, + Head: types.KVMap{"type": "text"}, + Content: "old", + ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, + }, nil) + messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ + TenantID: 7, + SeqId: 3, + Topic: "grpEdit", + From: "42", + Version: 2, + Head: types.KVMap{"type": "text"}, + Content: "new", + ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpEdit/messages/3/revisions", bytes.NewBufferString(`{"version":1,"content":"new"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMMessageRevision(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["status"] != "edited" || data["version"].(float64) != 2 || data["content"] != "new" { + t.Fatalf("unexpected revision response: %#v", data) + } +} + +func ptrTime(t time.Time) *time.Time { + return &t +} + func ptrString(s string) *string { return &s } diff --git a/server/main.go b/server/main.go index 3e316f9..11d175e 100644 --- a/server/main.go +++ b/server/main.go @@ -782,6 +782,17 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify) mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify) mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm) + mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges) + mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify) + mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName) + mux.HandleFunc(config.ApiPath+"v1/im/security/realname/submissions", serveIMRealNameSubmissions) + mux.HandleFunc(config.ApiPath+"v1/im/bootstrap", serveIMBootstrap) + mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password", serveIMIndependentPassword) + mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify) + mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch) + mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications) + mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) + mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) // Handle long polling clients. Enable compression. mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) if config.Media != nil { diff --git a/server/session.go b/server/session.go index 6ffd3cd..be601f8 100644 --- a/server/session.go +++ b/server/session.go @@ -762,6 +762,10 @@ func (s *Session) publish(msg *ClientComMessage) { logs.Err.Println("s.publish: sub.broadcast channel full, topic ", msg.RcptTo, s.sid) } } else if msg.RcptTo == "sys" { + if s.authLvl != auth.LevelRoot { + s.queueOut(ErrPermissionDeniedReply(msg, msg.Timestamp)) + return + } // Publishing to "sys" topic requires no subscription. select { case globals.hub.routeCli <- msg: diff --git a/server/session_test.go b/server/session_test.go index df491be..e82eed4 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -338,6 +338,27 @@ func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) { } } +func TestPublishSysRequiresRoot(t *testing.T) { + s := &Session{ + send: make(chan any, 1), + uid: types.Uid(42), + authLvl: auth.LevelAuth, + tenantID: 1, + tenantCode: "test", + } + s.publish(&ClientComMessage{ + Original: "sys", + RcptTo: "sys", + AsUser: types.Uid(42).UserId(), + Timestamp: time.Now(), + Pub: &MsgClientPub{Id: "pub-1", Topic: "sys", Content: map[string]any{"type": "system"}}, + }) + resp := (<-s.send).(*ServerComMessage) + if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusForbidden { + t.Fatalf("unexpected response: %+v", resp) + } +} + func TestDispatchRejectsCrossTenantAsUser(t *testing.T) { ctrl := gomock.NewController(t) users := mock_store.NewMockUsersPersistenceInterface(ctrl) diff --git a/server/store/mock_store/mock_store.go b/server/store/mock_store/mock_store.go index 6a0a6e0..cdf3673 100644 --- a/server/store/mock_store/mock_store.go +++ b/server/store/mock_store/mock_store.go @@ -1098,6 +1098,21 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteList", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).DeleteList), tenantID, topic, delID, forUser, msgDelAge, ranges) } +// Edit mocks base method. +func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) + ret0, _ := ret[0].(*types.Message) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Edit indicates an expected call of Edit. +func (mr *MockMessagesPersistenceInterfaceMockRecorder) Edit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Edit", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Edit), tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) +} + // GetAll mocks base method. func (m *MockMessagesPersistenceInterface) GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) { m.ctrl.T.Helper() @@ -1129,12 +1144,42 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, top return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt) } +// GetOne mocks base method. +func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq) + ret0, _ := ret[0].(*types.Message) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOne indicates an expected call of GetOne. +func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic, seq interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOne", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetOne), tenantID, topic, seq) +} + +// Recall mocks base method. +func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt) + ret0, _ := ret[0].(*types.Message) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Recall indicates an expected call of Recall. +func (mr *MockMessagesPersistenceInterfaceMockRecorder) Recall(tenantID, topic, seq, actor, expectedVersion, recalledAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recall", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Recall), tenantID, topic, seq, actor, expectedVersion, recalledAt) +} + // Save mocks base method. -func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) { +func (m *MockMessagesPersistenceInterface) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Save", tenantID, msg, attachmentURLs, readBySender) - ret0, _ := ret[0].(error) - ret1, _ := ret[1].(bool) + ret0, _ := ret[0].(*types.MessageSaveResult) + ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/server/store/store.go b/server/store/store.go index c4cc6c2..f565831 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -953,7 +953,10 @@ func (subsMapper) Delete(tenantID types.TenantID, topic string, user types.Uid) // MessagesPersistenceInterface is an interface which defines methods for persistent storage of messages. type MessagesPersistenceInterface interface { - Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) + Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) + GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) + Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) + Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) @@ -965,33 +968,84 @@ type messagesMapper struct{} // Messages is a singleton ancor object for exporting MessagesPersistenceInterface. var Messages MessagesPersistenceInterface -// Save message -func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (error, bool) { - if msg == nil || msg.TenantID != tenantID { - return types.ErrMalformed, false +func (messagesMapper) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMessageGetOne(tenantID, topic, seq) +} + +func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt) +} + +func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { + if err := validateMessageHeaders(head); err != nil { + return nil, err } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { - return err, false + return nil, err + } + return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) +} + +// Save message +func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) { + if msg == nil || msg.TenantID != tenantID { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + clientMsgID, err := normalizeClientMsgID(msg.Head) + if err != nil { + return nil, err + } + msg.ClientMsgID = clientMsgID + fromUid := types.ParseUid(msg.From) + if clientMsgID != "" { + duplicate, err := tenantAdp.TenantMessageGetByClientID(tenantID, fromUid, clientMsgID) + if err != nil { + return nil, err + } + if duplicate != nil { + msg.ObjHeader = duplicate.ObjHeader + msg.DeletedAt = duplicate.DeletedAt + msg.DelId = duplicate.DelId + msg.DeletedFor = duplicate.DeletedFor + msg.SeqId = duplicate.SeqId + msg.Topic = duplicate.Topic + msg.From = duplicate.From + msg.ClientMsgID = duplicate.ClientMsgID + msg.Head = duplicate.Head + msg.Content = duplicate.Content + return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil + } } msg.InitTimes() msg.SetUid(Store.GetUid()) // Increment topic's or user's SeqId err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg) if err != nil { - return err, false + return nil, err } err = tenantAdp.TenantMessageSave(tenantID, msg) if err != nil { - return err, false + return nil, err } markedReadBySender := false // Mark message as read by the sender. if readBySender { // Make sure From is valid, otherwise we will reset values for all subscribers. - fromUid := types.ParseUid(msg.From) if !fromUid.IsZero() { // Ignore the error here. It's not a big deal if it fails. if subErr := tenantAdp.TenantSubsUpdate(tenantID, msg.Topic, fromUid, @@ -1014,11 +1068,74 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme } } if len(attachments) > 0 { - return tenantAdp.TenantFileLinkAttachments(tenantID, "", types.ZeroUid, msg.Uid(), attachments), markedReadBySender + if err := tenantAdp.TenantFileLinkAttachments(tenantID, "", types.ZeroUid, msg.Uid(), attachments); err != nil { + return nil, err + } } } - return nil, markedReadBySender + return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: markedReadBySender}, nil +} + +func normalizeClientMsgID(head types.KVMap) (string, error) { + if head == nil { + return "", nil + } + if err := validateMessageHeaders(head); err != nil { + return "", err + } + value, ok := head["x-im-client-mid"] + if !ok { + return "", nil + } + clientMsgID, ok := value.(string) + if !ok { + return "", types.ErrMalformed + } + clientMsgID = strings.TrimSpace(clientMsgID) + if len(clientMsgID) > 64 { + return "", types.ErrMalformed + } + return clientMsgID, nil +} + +func validateMessageHeaders(head types.KVMap) error { + if schema, ok := head["x-im-schema"]; ok { + value, ok := schema.(string) + if !ok || !validIMMessageHeaderToken(value, 32) { + return types.ErrMalformed + } + } + if msgType, ok := head["type"]; ok { + value, ok := msgType.(string) + if !ok || !validIMMessageType(value) { + return types.ErrMalformed + } + } + return nil +} + +func validIMMessageHeaderToken(value string, max int) bool { + value = strings.TrimSpace(value) + if value == "" || len(value) > max { + return false + } + for _, ch := range value { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' { + continue + } + return false + } + return true +} + +func validIMMessageType(value string) bool { + switch strings.TrimSpace(value) { + case "text", "image", "file", "audio", "video", "location", "contact", "card", "call", "system": + return true + default: + return false + } } // DeleteList deletes multiple messages defined by a list of ranges. @@ -1469,6 +1586,16 @@ var Verifications VerificationPersistenceInterface type UserSecurityPersistenceInterface interface { GetAuthVersion(tenantID types.TenantID, uid types.Uid) (int, error) BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error) + GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) + CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error + CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error + ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) + GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) + UpsertIndependentPassword(tenantID types.TenantID, pass *types.IndependentPassword) error + UpdateIndependentPasswordAttempts(tenantID types.TenantID, uid types.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error + CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error + ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) + MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error } type userSecurityMapper struct{} @@ -1492,6 +1619,98 @@ func (userSecurityMapper) BumpAuthVersion(tenantID types.TenantID, uid types.Uid return tenantAdp.TenantUserSecurityBumpAuthVersion(tenantID, uid, updatedAt) } +func (userSecurityMapper) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantRealNameGet(tenantID, uid) +} + +func (userSecurityMapper) CreateRealNameSubmission(tenantID types.TenantID, rv *types.RealNameVerification) error { + if rv == nil || rv.TenantID != tenantID { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantRealNameCreate(tenantID, rv) +} + +func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *types.ActionToken) error { + if token == nil || token.TenantID != tenantID { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantActionTokenCreate(tenantID, token) +} + +func (userSecurityMapper) ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantActionTokenConsume(tenantID, uid, token, action, target, now) +} + +func (userSecurityMapper) GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantIndependentPasswordGet(tenantID, uid, purpose) +} + +func (userSecurityMapper) UpsertIndependentPassword(tenantID types.TenantID, pass *types.IndependentPassword) error { + if pass == nil || pass.TenantID != tenantID { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantIndependentPasswordUpsert(tenantID, pass) +} + +func (userSecurityMapper) UpdateIndependentPasswordAttempts(tenantID types.TenantID, uid types.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantIndependentPasswordUpdateAttempts(tenantID, uid, purpose, attempts, lockedUntil, updatedAt) +} + +func (userSecurityMapper) CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error { + if notification == nil || notification.TenantID != tenantID { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantSystemNotificationCreate(tenantID, notification) +} + +func (userSecurityMapper) ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantSystemNotificationList(tenantID, uid, cursor, limit) +} + +func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error { + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantSystemNotificationMarkRead(tenantID, uid, eventID, readAt) +} + func (verificationMapper) Create(tenantID types.TenantID, vc *types.VerificationCode) error { if vc == nil || vc.TenantID != tenantID { return types.ErrMalformed diff --git a/server/store/types/notification.go b/server/store/types/notification.go new file mode 100644 index 0000000..3910482 --- /dev/null +++ b/server/store/types/notification.go @@ -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:"-"` +} diff --git a/server/store/types/security.go b/server/store/types/security.go new file mode 100644 index 0000000..031acf7 --- /dev/null +++ b/server/store/types/security.go @@ -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:"-"` +} diff --git a/server/store/types/types.go b/server/store/types/types.go index 3102d81..56104e2 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -1228,9 +1228,33 @@ type Message struct { SeqId int Topic string // Sender's user ID as string (without 'usr' prefix), could be empty. - From string - Head KVMap `json:"Head,omitempty" bson:",omitempty"` - Content any + From string + ClientMsgID string `db:"client_msg_id" json:"ClientMsgId,omitempty" bson:"client_msg_id,omitempty"` + Version int `json:"Version,omitempty" bson:",omitempty"` + RecalledAt *time.Time `db:"recalled_at" json:"RecalledAt,omitempty" bson:"recalled_at,omitempty"` + RecalledBy string `json:"RecalledBy,omitempty" bson:"recalled_by,omitempty"` + Head KVMap `json:"Head,omitempty" bson:",omitempty"` + Content any +} + +// MessageRevision stores an immutable snapshot of a message before editing. +type MessageRevision struct { + ObjHeader `bson:",inline"` + TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` + Topic string + SeqId int + Version int + Actor string + Head KVMap `json:"Head,omitempty" bson:",omitempty"` + Content any + Reason string +} + +// MessageSaveResult describes the outcome of a message persistence attempt. +type MessageSaveResult struct { + SeqId int + MarkedReadBySender bool + Duplicate bool } // Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi). diff --git a/server/store/types/verification.go b/server/store/types/verification.go index 88750f4..e45089f 100644 --- a/server/store/types/verification.go +++ b/server/store/types/verification.go @@ -19,6 +19,7 @@ type VerificationCode struct { TenantID TenantID `db:"tenant_id" json:"-"` ChallengeID string `db:"challenge_id" json:"challenge_id"` Scene string `db:"scene" json:"scene"` + Action string `db:"action" json:"action,omitempty"` PhoneHash string `db:"phone_hash" json:"-"` PhoneMask string `db:"phone_mask" json:"phone_mask,omitempty"` TargetUser Uid `db:"target_user" json:"-"` diff --git a/server/topic.go b/server/topic.go index c88d33a..aae0923 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1013,8 +1013,12 @@ func (t *Topic) sendSubNotifications(asUid types.Uid, sid, userAgent string) { // in response to a client request (msg, asUid) and broadcasts it to the attached sessions. func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, noEcho bool, attachments []string, head map[string]any, content any) error { pud, userFound := t.perUser[asUid] - // Anyone is allowed to post to 'sys' topic. - if t.cat != types.TopicCatSys { + if t.cat == types.TopicCatSys { + if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot { + msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) + return types.ErrPermissionDenied + } + } else { // If it's not 'sys' check write permission. if !(pud.modeWant & pud.modeGiven).IsWriter() { msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) @@ -1032,9 +1036,14 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, // Make sure the received Head does not include a fake "sender" header. delete(head, "sender") } + if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot { + if msgType, _ := head["type"].(string); msgType == "system" { + msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) + return types.ErrPermissionDenied + } + } - markedReadBySender := false - if err, unreadUpdated := store.Messages.Save(t.tenantID, + saveResult, err := store.Messages.Save(t.tenantID, &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp}, TenantID: t.tenantID, @@ -1043,14 +1052,22 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, From: asUid.String(), Head: head, Content: content, - }, attachments, (pud.modeGiven & pud.modeWant).IsReader()); err != nil { + }, attachments, (pud.modeGiven & pud.modeWant).IsReader()) + if err != nil { logs.Warn.Printf("topic[%s]: failed to save message: %v", t.name, err) msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp)) return err - } else { - markedReadBySender = unreadUpdated } + if saveResult != nil && saveResult.Duplicate { + if msg.Id != "" && msg.sess != nil { + reply := NoErrAccepted(msg.Id, t.original(asUid), msg.Timestamp) + reply.Ctrl.Params = map[string]any{"seq": saveResult.SeqId} + msg.sess.queueOut(reply) + } + return nil + } + markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender t.lastID++ t.touched = msg.Timestamp diff --git a/server/topic_test.go b/server/topic_test.go index f2ad2fb..a44b888 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -200,7 +200,8 @@ func TestHandleBroadcastDataP2P(t *testing.T) { helper := TopicTestHelper{} helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true) defer helper.tearDown() - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) from := helper.uids[0].UserId() msg := &ClientComMessage{ @@ -285,7 +286,8 @@ func TestHandleBroadcastCall(t *testing.T) { globals.iceServers = []iceServer{{Username: "dummy"}} helper.topic.lastID = 5 defer helper.tearDown() - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) from := helper.uids[0].UserId() msg := &ClientComMessage{ @@ -397,7 +399,8 @@ func TestHandleBroadcastDataGroup(t *testing.T) { store.Messages = nil helper.tearDown() }() - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) // User 3 isn't allowed to read. pu3 := helper.topic.perUser[helper.uids[3]] @@ -557,7 +560,7 @@ func TestHandleBroadcastDataDbError(t *testing.T) { defer helper.tearDown() // DB returns an error. - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(types.ErrInternal, false) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, types.ErrInternal) // Make test message. from := helper.uids[0].UserId() @@ -605,6 +608,104 @@ func TestHandleBroadcastDataDbError(t *testing.T) { } } +func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() + helper.topic.lastID = 7 + + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 4, Duplicate: true}, nil) + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + Id: "retry-1", + AsUser: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Head: map[string]any{"x-im-client-mid": "mid-1"}, + Content: "test", + }, + sess: helper.sessions[0], + } + + helper.topic.handleClientMsg(msg) + helper.finish() + + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + if helper.topic.lastID != 7 { + t.Errorf("Topic.lastID: expected duplicate to keep 7, found %d", helper.topic.lastID) + } + if len(helper.results[0].messages) != 1 { + t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages)) + } + ack := helper.results[0].messages[0].(*ServerComMessage) + if ack.Ctrl == nil { + t.Fatal("User 1 is expected to receive a ctrl message") + } + if ack.Ctrl.Code != 202 { + t.Errorf("User1: expected ctrl.code 202, received %d", ack.Ctrl.Code) + } + params, ok := ack.Ctrl.Params.(map[string]any) + if !ok { + t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params) + } + if seq, ok := params["seq"].(int); !ok || seq != 4 { + t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"]) + } + if len(helper.results[1].messages) != 0 { + t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) + } + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + Id: "sys-1", + AsUser: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Head: map[string]any{"type": "system"}, + Content: "test", + }, + sess: helper.sessions[0], + } + + helper.topic.handleClientMsg(msg) + helper.finish() + + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + if helper.topic.lastID != 0 { + t.Errorf("Topic.lastID: expected forbidden publish to keep 0, found %d", helper.topic.lastID) + } + if len(helper.results[0].messages) != 1 { + t.Fatalf("User 1 is expected to receive one ctrl message vs %d received.", len(helper.results[0].messages)) + } + resp := helper.results[0].messages[0].(*ServerComMessage) + if resp.Ctrl == nil || resp.Ctrl.Code != 403 { + t.Fatalf("User 1 expected 403 ctrl, received %#v", resp) + } + if len(helper.results[1].messages) != 0 { + t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) + } + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + func TestHandleBroadcastDataInactiveTopic(t *testing.T) { numUsers := 2 helper := TopicTestHelper{} @@ -2876,7 +2977,8 @@ func TestHandleBroadcastDataWithAttachments(t *testing.T) { helper := TopicTestHelper{} helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) defer helper.tearDown() - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) from := helper.uids[0].UserId() msg := &ClientComMessage{ @@ -3039,7 +3141,8 @@ func TestHandleBroadcastDataGroupWithMutedUser(t *testing.T) { helper := TopicTestHelper{} helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) defer helper.tearDown() - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) // User 2 has muted the topic (no Pres permission) pu2 := helper.topic.perUser[helper.uids[2]] @@ -3103,7 +3206,8 @@ func TestUnregisterSessionWithPendingCall(t *testing.T) { isOriginator: true, sess: s, } - helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) leave := &ClientComMessage{ Leave: &MsgClientLeave{ diff --git a/server/user_verification_test.go b/server/user_verification_test.go index 3df8ab5..05c885a 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -210,6 +210,14 @@ type testSecurityStore struct { err error bumped bool bumpedUser types.Uid + + realName *types.RealNameVerification + actionToken *types.ActionToken + independentPassword *types.IndependentPassword + attempts int + lockedUntil *time.Time + notifications []types.SystemNotification + readNotificationID string } func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) { @@ -228,3 +236,83 @@ func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ t s.version++ return s.version, s.err } + +func (s *testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) { + if s.err != nil { + return nil, s.err + } + if s.realName == nil { + return nil, types.ErrNotFound + } + return s.realName, nil +} + +func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types.RealNameVerification) error { + s.realName = rv + return s.err +} + +func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error { + s.actionToken = token + return s.err +} + +func (s *testSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _ string, action string, target string, _ time.Time) (*types.ActionToken, error) { + if s.err != nil { + return nil, s.err + } + if s.actionToken == nil { + return &types.ActionToken{Action: action, Target: target}, nil + } + return s.actionToken, nil +} + +func (s *testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) { + if s.err != nil { + return nil, s.err + } + if s.independentPassword == nil { + return nil, types.ErrNotFound + } + return s.independentPassword, nil +} + +func (s *testSecurityStore) UpsertIndependentPassword(_ types.TenantID, pass *types.IndependentPassword) error { + s.independentPassword = pass + return s.err +} + +func (s *testSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, attempts int, lockedUntil *time.Time, _ time.Time) error { + s.attempts = attempts + s.lockedUntil = lockedUntil + if s.independentPassword != nil { + s.independentPassword.FailedAttempts = attempts + s.independentPassword.LockedUntil = lockedUntil + } + return s.err +} + +func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error { + if notification != nil { + s.notifications = append(s.notifications, *notification) + } + return s.err +} + +func (s *testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) { + return s.notifications, "", s.err +} + +func (s *testSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) error { + s.readNotificationID = eventID + for i := range s.notifications { + if s.notifications[i].EventID == eventID { + s.notifications[i].ReadAt = &readAt + return s.err + } + } + if s.err != nil { + return s.err + } + return types.ErrNotFound +} diff --git a/tinode-db/gendb.go b/tinode-db/gendb.go index 2409095..def321f 100644 --- a/tinode-db/gendb.go +++ b/tinode-db/gendb.go @@ -334,7 +334,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { if timestamp.After(now) { now = timestamp } - if err, _ = store.Messages.Save(tenantID, &types.Message{ + if _, err = store.Messages.Save(tenantID, &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: timestamp}, TenantID: tenantID, SeqId: seqId, @@ -357,7 +357,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { for _, gt := range data.Grouptopics { seqIds[nameIndex[gt.Name]] = 1 - if err, _ = store.Messages.Save(tenantID, &types.Message{ + if _, err = store.Messages.Save(tenantID, &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: now}, TenantID: tenantID, SeqId: 1, @@ -377,7 +377,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { } usedp2p[nameIndex[sub.pair]] = true seqIds[nameIndex[sub.pair]] = 1 - if err, _ = store.Messages.Save(tenantID, &types.Message{ + if _, err = store.Messages.Save(tenantID, &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: now}, TenantID: tenantID, SeqId: 1, @@ -401,7 +401,7 @@ func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { seqIds[nameIndex[sub.pair]]++ seqId := seqIds[nameIndex[sub.pair]] if sub.Users[0].Name == botAccount || sub.Users[1].Name == botAccount { - if err, _ = store.Messages.Save(tenantID, &types.Message{ + if _, err = store.Messages.Save(tenantID, &types.Message{ ObjHeader: types.ObjHeader{CreatedAt: ts}, TenantID: tenantID, SeqId: seqId,