From 31f82d9c4147cc8254eac44ebfe18a01c3bba81d Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 13 Aug 2026 11:30:48 +0800 Subject: [PATCH] feat: add im tenant db routing and chapter seven flows --- docs/IM-第一二章后续增强项.md | 118 +- docs/IM-第一至七章数据库增量审核.sql | 316 +++ scripts/apply_hotgo_tenant_db.go | 109 + server/db/adapter.go | 16 + server/db/mysql/adapter.go | 285 ++- server/db/mysql/schema.sql | 31 +- server/db/mysql/tenant_adapter.go | 426 ++++ server/hdl_files.go | 62 +- server/hdl_files_test.go | 43 + server/im_http.go | 2657 ++++++++++++++++++++++++- server/im_http_test.go | 976 +++++++++ server/main.go | 9 + server/store/mock_store/mock_store.go | 31 + server/store/store.go | 184 ++ server/store/types/favorites.go | 20 + server/store/types/relations.go | 13 + server/store/types/tenant.go | 2 + server/tinode.conf | 6 +- tinode-db/tinode.conf | 4 +- 19 files changed, 5261 insertions(+), 47 deletions(-) create mode 100644 docs/IM-第一至七章数据库增量审核.sql create mode 100644 scripts/apply_hotgo_tenant_db.go create mode 100644 server/hdl_files_test.go create mode 100644 server/store/types/favorites.go diff --git a/docs/IM-第一二章后续增强项.md b/docs/IM-第一二章后续增强项.md index 5c75917..0f274b8 100644 --- a/docs/IM-第一二章后续增强项.md +++ b/docs/IM-第一二章后续增强项.md @@ -1,7 +1,7 @@ -# IM 第一、二章后续增强项 +# IM 第一至六章后续增强项 -> 日期:2026-08-12 -> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知 +> 日期:2026-08-13 +> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理 > 用途:记录当前已完成基线之外的增强项,后续统一排期处理。 ## 1. 第一章:登录、注册与安全 @@ -63,9 +63,113 @@ | P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | | P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 | -## 3. 统一处理建议 +## 3. 第三章:单聊关系、权限与会话上下文 -1. 先做 P0:统一幂等、编辑/撤回实时事件、撤回占位、验证码真实发送与限频。 -2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理。 -3. 最后做 P2:端到端重连测试、schema 版本演进和体验型优化。 +### 3.1 当前已完成基线 + +1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。 +2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。 +3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。 +4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 +5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。 + +### 3.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | +| P0 | 关系变更实时同步 | 好友通过、删除、拉黑、解除拉黑后,应向双方在线端推送关系和会话能力变化。 | A 拉黑 B 后,B 端输入框无需刷新自动锁定。 | +| P0 | 关系事件 Outbox | 关系 DB 状态变化与系统通知、会话事件、Push 需要事务级衔接。 | 模拟推送失败后后台可补发,不产生重复事件。 | +| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 | +| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | +| P1 | 非好友资料隐私裁剪 | 单聊上下文中的对端资料需要接入隐私设置、企业可见性和青少年模式。 | 非好友只能看到允许公开字段;好友看到扩展字段。 | +| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | +| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 | + +## 4. 第四章:群消息、群成员与 @ 能力 + +### 4.1 当前已完成基线 + +1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。 +2. 返回成员角色、公开资料、ACL 和是否可被 @。 +3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。 +4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。 +5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。 + +### 4.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 | +| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 | +| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 | +| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 | +| P1 | 群昵称/角色变更事件 | 群昵称、角色、禁言、移除等变化需要实时推送给群成员并刷新成员列表缓存。 | 群主修改成员角色后在线端成员列表自动更新。 | +| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 | +| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 | +| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 | + +## 5. 第五章:通讯录、好友关系与黑名单 + +### 5.1 当前已完成基线 + +1. 好友列表支持查询、首字母、分页,返回备注、头像、显示名来源和会话 Topic。 +2. 新朋友列表、好友申请、通过、拒绝链路已具备。 +3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。 +4. 添加好友二维码支持短期签名 Token 生成与解析。 +5. 好友个人主页返回关系、资料和操作能力。 +6. 群内非好友个人主页返回群角色、关系、资料和操作能力。 +7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。 +8. 黑名单列表和批量移出黑名单已具备。 + +### 5.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 好友/黑名单操作统一幂等 | 拉黑、解除、删除、批量移出、好友申请等需要统一接入 `Idempotency-Key` 存储和请求摘要。 | 客户端超时重试不会重复创建申请或重复触发事件。 | +| P0 | 好友关系实时事件 | 申请、通过、拒绝、删除、备注、拉黑等状态变化需要向相关在线端实时同步。 | 两端关系页、会话页和通讯录无需刷新自动更新。 | +| P0 | 用户搜索隐私策略 | 当前搜索基线可用,后续需要接入 `allow_user_search`、添加方式、手机号可搜索等租户和个人隐私设置。 | 禁止手机号添加的用户无法通过手机号被搜索到,且不泄露存在性。 | +| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 | +| P1 | 好友资料字段精细裁剪 | 个人主页需按好友、非好友、群内关系、黑名单、实名/青少年等规则返回不同字段。 | 不同入口返回字段符合隐私矩阵。 | +| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 | +| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 | +| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 | +| P1 | 关系审计和安全通知 | 添加、删除、拉黑、解除、二维码解析等应写审计并按策略生成系统通知。 | 用户可在通知中看到关键关系事件,后台可追溯。 | +| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 | + +## 6. 第六章:群聊创建、资料维护与成员治理 + +### 6.1 当前已完成基线 + +1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。 +2. 群设置、个人群设置、退出群、群昵称接口已具备。 +3. 群二维码生成与扫码解析支持 `join_group` 场景。 +4. 群邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。 +5. 群公告最新查询、发布/更新、详情和已读接口已具备。 +6. 群治理配置 `GET/PATCH /governance` 已具备。 +7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。 +8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 +9. 当前实现优先复用 Topic、Subscription 和 Topic Aux,把产品主链路接口跑通。 + +### 6.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 群业务独立表拆分 | 当前群公告、进群申请、治理配置、禁言记录先存 Topic Aux,后续需要拆为独立表。 | 公告、申请、治理、禁言均有 tenant/topic 维度索引,可稳定分页和审计。 | +| P0 | 群治理强事务状态机 | 邀请、申请审批、管理员变更、群主转让、禁言需在事务内同步业务表、订阅 ACL、成员数和事件。 | 并发审批/转让/禁言不会出现 ACL 与业务状态不一致。 | +| P0 | 第六章幂等键落库 | 邀请、申请处理、公告发布、治理 PATCH、管理员批量、群主转让、禁言批量都需要统一幂等。 | 超时重试同一请求返回同一结果,不重复加人、升权或发事件。 | +| P0 | 系统事件、Push 与 Outbox | 第六章所有成员/公告/治理变化需要生成可信群系统事件,并通过 Outbox 保证推送。 | 在线端实时刷新;模拟推送失败后可补偿且不重复。 | +| P1 | 群公告已读独立记录 | 当前公告已读返回基础结果,后续需记录每个成员的公告已读时间。 | 新公告只提醒未读成员,已读状态跨端同步。 | +| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | +| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | +| P1 | 禁言发送链路强校验 | 当前禁言接口已具备,后续需要在 WebSocket 发消息路径校验 `GROUP_MUTED`。 | 被禁言成员发群消息被服务端拒绝,过期后自动恢复。 | +| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 | +| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | +| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 | + +## 7. 统一处理建议 + +1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机。 +2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验和群文件索引。 +3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化。 4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。 diff --git a/docs/IM-第一至七章数据库增量审核.sql b/docs/IM-第一至七章数据库增量审核.sql new file mode 100644 index 0000000..bb6c62b --- /dev/null +++ b/docs/IM-第一至七章数据库增量审核.sql @@ -0,0 +1,316 @@ +-- IM 第一至七章数据库增量审核稿 +-- 仅供审核,不要直接在生产库盲执行。 +-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。 +-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。 +-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。 + +SET NAMES utf8mb4; + +-- hotgo 公共后台库需要先存在;如果你审核通过,可先执行: +-- CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +-- USE hotgo; +-- +-- 租户业务库 tinode 也需要存在并完成 Tinode 基础表初始化: +-- CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 1. 租户、认证、安全基础表 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS im_tenant( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'tenant business database name, e.g. tinode_acme', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'optional full tenant DB DSN, empty means reuse base MySQL config', + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_code(code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) +VALUES('default','Default Tenant',NULL,'tinode','',1,0,0) +ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3); + +CREATE TABLE IF NOT EXISTS im_tenant_config( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id), + CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_verification_codes( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + challenge_id VARCHAR(64) NOT NULL, + scene VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL DEFAULT '', + phone_hash CHAR(64) NOT NULL, + phone_mask VARCHAR(32) NOT NULL, + target_user BIGINT, + code_hash CHAR(64) NOT NULL, + verification_token_hash CHAR(64), + state SMALLINT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL, + expires_at DATETIME(3) NOT NULL, + resend_after DATETIME(3) NOT NULL, + verified_at DATETIME(3), + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id), + INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at), + CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_user_security( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + auth_version INT NOT NULL DEFAULT 1, + password_updated_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id,user_id), + CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_realname_verifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + submission_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + name_cipher TEXT NOT NULL, + id_type VARCHAR(32) NOT NULL, + id_number_cipher TEXT NOT NULL, + id_number_hash CHAR(64) NOT NULL, + masked_name VARCHAR(64) NOT NULL, + masked_id_number VARCHAR(64) NOT NULL, + review_reason VARCHAR(256), + verified_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id), + INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at), + INDEX idx_im_realname_id_hash(tenant_id, id_number_hash), + CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_action_tokens( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + token_id VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(128) NOT NULL DEFAULT '', + token_hash CHAR(64) NOT NULL, + state SMALLINT NOT NULL, + expires_at DATETIME(3) NOT NULL, + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id), + INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at), + CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_youth_mode_settings( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + purpose VARCHAR(64) NOT NULL, + password_hash VARBINARY(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, user_id, purpose), + CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_system_notifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL, + action JSON, + read_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id), + INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC), + CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 2. 好友、黑名单 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS im_friend_requests( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + request_id VARCHAR(64) NOT NULL, + applicant_id BIGINT NOT NULL, + target_id BIGINT NOT NULL, + message VARCHAR(200) NOT NULL DEFAULT '', + source VARCHAR(32) NOT NULL DEFAULT '', + source_topic VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + handled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_friend_requests_request(tenant_id, request_id), + INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at), + INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at), + CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE UNIQUE INDEX uk_im_friend_requests_idempotency + ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, ''))); + +CREATE TABLE IF NOT EXISTS im_friendships( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + peer_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + remark VARCHAR(64) NOT NULL DEFAULT '', + topic CHAR(25) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, user_id, peer_id), + INDEX idx_im_friendships_peer(tenant_id, peer_id, status), + CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_user_blocks( + tenant_id BIGINT NOT NULL, + blocker_id BIGINT NOT NULL, + blocked_id BIGINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, blocker_id, blocked_id), + INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), + CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 3. 收藏、转发、消息增强 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS im_favorites( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + favorite_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + item_type VARCHAR(16) NOT NULL, + source_topic CHAR(25) NOT NULL, + source_seq INT NOT NULL, + snapshot JSON, + attachment_refs JSON, + availability VARCHAR(16) NOT NULL DEFAULT 'available', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + collected_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id), + UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq), + INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC), + INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC), + CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE UNIQUE INDEX uk_im_favorites_idempotency + ON im_favorites(tenant_id, user_id, (NULLIF(idempotency_key, ''))); + +CREATE TABLE IF NOT EXISTS im_message_revisions( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + topic CHAR(25) NOT NULL, + seqid INT NOT NULL, + version INT NOT NULL, + actor BIGINT NOT NULL, + head JSON, + content JSON, + reason VARCHAR(256) NOT NULL DEFAULT '', + PRIMARY KEY(id), + UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), + CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 4. Tinode 基础表需补齐的字段/索引 +-- 如果你的库是用当前 schema.sql 初始化,这些已经存在。 +-- 如果是旧库,需要逐项核对后再 ALTER。 +-- ============================================================ + +-- users:租户隔离字段和索引。 +-- ALTER TABLE users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX users_tenant_id_id ON users(tenant_id, id); + +-- topics:租户隔离、群治理 Aux、租户维度唯一索引。 +-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 topics.aux JSON。 +-- 后续若要强状态机和审计,应拆 im_group_join_requests、im_group_announcements、im_group_mutes 等表。 +-- ALTER TABLE topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE topics ADD COLUMN aux JSON AFTER tags; +-- CREATE UNIQUE INDEX topics_tenant_name ON topics(tenant_id, name); +-- CREATE INDEX topics_tenant_owner ON topics(tenant_id, owner); + +-- subscriptions:租户字段和按租户查成员索引。 +-- ALTER TABLE subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX subscriptions_tenant_userid ON subscriptions(tenant_id, userid); +-- CREATE INDEX subscriptions_tenant_topic ON subscriptions(tenant_id, topic); +-- CREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat); + +-- messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。 +-- ALTER TABLE messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`; +-- ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id; +-- ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version; +-- ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at; +-- CREATE INDEX messages_tenant_topic ON messages(tenant_id, topic); +-- CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id); + +-- dellog:用户侧删除过滤需要 tenant_id。 +-- ALTER TABLE dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX dellog_tenant_topic ON dellog(tenant_id, topic); +-- CREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic, deletedfor, low, hi); + +-- fileuploads:文件 ACL、租户隔离、ETag。 +-- ALTER TABLE fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128) AFTER size; +-- CREATE INDEX fileuploads_tenant_status ON fileuploads(tenant_id, status); + +-- filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。 +-- ALTER TABLE filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid; +-- ALTER TABLE filemsglinks ADD COLUMN userid BIGINT AFTER topic; +-- CREATE INDEX filemsglinks_tenant_fileid ON filemsglinks(tenant_id, fileid); + +-- usertags/topictags/devices/auth/credentials 也已按租户增加 tenant_id; +-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。 diff --git a/scripts/apply_hotgo_tenant_db.go b/scripts/apply_hotgo_tenant_db.go new file mode 100644 index 0000000..fb0a0c4 --- /dev/null +++ b/scripts/apply_hotgo_tenant_db.go @@ -0,0 +1,109 @@ +//go:build ignore + +package main + +import ( + "database/sql" + "fmt" + "os" + "strings" + + _ "github.com/go-sql-driver/mysql" +) + +func main() { + dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://") + if dsn == "" { + fatal("IM_MYSQL_DSN is required") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + fatal(err) + } + defer db.Close() + if err = db.Ping(); err != nil { + fatal(err) + } + + statements := []string{ + "CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci", + `CREATE TABLE IF NOT EXISTS hotgo.im_tenant( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_code(code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id), + CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + "CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci", + } + for _, stmt := range statements { + if _, err = db.Exec(stmt); err != nil { + fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) + } + } + for _, stmt := range []string{ + "ALTER TABLE hotgo.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name", + "ALTER TABLE hotgo.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc", + "ALTER TABLE hotgo.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name", + "ALTER TABLE tinode.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name", + "ALTER TABLE tinode.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc", + "ALTER TABLE tinode.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name", + } { + if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") { + fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) + } + } + if _, err = db.Exec(`INSERT INTO hotgo.im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) + VALUES('default','Default Tenant',NULL,'tinode','',1,0,0) + ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3)`); err != nil { + fatal(err) + } + if _, err = db.Exec(`INSERT INTO hotgo.im_tenant_config(tenant_id,max_users,max_groups,max_group_members,created_by,updated_by) + SELECT id,0,0,0,0,0 FROM hotgo.im_tenant WHERE code='default' + ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil { + fatal(err) + } + if _, err = db.Exec("UPDATE tinode.im_tenant SET db_name='tinode' WHERE code='default' AND (db_name='' OR db_name IS NULL)"); err != nil { + fatal(err) + } + + var code, dbName string + var state int + if err = db.QueryRow("SELECT code, db_name, state FROM hotgo.im_tenant WHERE code='default'").Scan(&code, &dbName, &state); err != nil { + fatal(err) + } + fmt.Printf("hotgo.im_tenant default: code=%s db_name=%s state=%d\n", code, dbName, state) + var columnCount int + if err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME='im_tenant' AND COLUMN_NAME IN ('tenant_desc','db_name','db_dsn')`).Scan(&columnCount); err != nil { + fatal(err) + } + fmt.Printf("tinode.im_tenant routing columns: %d/3\n", columnCount) +} + +func fatal(v any) { + fmt.Fprintln(os.Stderr, v) + os.Exit(1) +} diff --git a/server/db/adapter.go b/server/db/adapter.go index 98744c8..849f7b6 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -215,6 +215,12 @@ type TenantAdapter interface { TenantGetByCode(code string) (*t.Tenant, error) } +// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the +// "one tenant, one database" deployment model. +type TenantRoutedAdapter interface { + TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error) +} + // TenantAuthAdapter is the tenant-aware authentication and credential capability. // It is separate from Adapter while the remaining persistence domains are migrated. type TenantAuthAdapter interface { @@ -281,6 +287,12 @@ type TenantBusinessAdapter interface { 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) + TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error) + + TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error) + TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error) + TenantFavoriteGet(t.TenantID, t.Uid, string) (*t.FavoriteItem, error) + TenantFavoriteDelete(t.TenantID, t.Uid, string) error TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) @@ -291,6 +303,7 @@ type TenantBusinessAdapter interface { TenantFileGet(t.TenantID, string) (*t.FileDef, error) TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error) TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error + TenantFileUserHasAccess(t.TenantID, string, t.Uid) (bool, error) TenantPCacheGet(t.TenantID, string) (string, error) TenantPCacheUpsert(t.TenantID, string, string, bool) error @@ -326,8 +339,11 @@ type TenantBusinessAdapter interface { TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error) TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, error) + TenantFriendshipUpdateRemark(t.TenantID, t.Uid, t.Uid, string, time.Time) (*t.Friendship, error) TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, error) + TenantUserBlockList(t.TenantID, t.Uid, string, int) ([]t.BlockListItem, string, error) TenantUserBlockUpsert(t.TenantID, t.Uid, t.Uid, time.Time) (*t.UserBlock, error) TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error + TenantUserSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.User, string, error) } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 727b3dd..2a5e3ef 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -13,11 +13,13 @@ import ( "sort" "strconv" "strings" + "sync" "time" ms "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" "github.com/tinode/chat/server/auth" + dbadapter "github.com/tinode/chat/server/db" "github.com/tinode/chat/server/db/common" "github.com/tinode/chat/server/store" t "github.com/tinode/chat/server/store/types" @@ -25,9 +27,10 @@ import ( // adapter holds MySQL connection data. type adapter struct { - db *sqlx.DB - dsn string - dbName string + db *sqlx.DB + registryDB *sqlx.DB + dsn string + dbName string // Maximum number of records to return maxResults int // Maximum number of message records to return @@ -38,6 +41,12 @@ type adapter struct { sqlTimeout time.Duration // DB transaction timeout. txTimeout time.Duration + + baseConfig *configType + tenantDBRouting bool + tenantRegistryDB string + tenantAdapters map[t.TenantID]*adapter + tenantMu sync.Mutex } const ( @@ -78,6 +87,11 @@ type configType struct { // DB request timeout (in seconds). // If 0 (or negative), no timeout is applied. SqlTimeout int `json:"sql_timeout,omitempty"` + + // TenantRegistryDB is the public backend database, e.g. hotgo. + TenantRegistryDB string `json:"tenant_registry_db,omitempty"` + // TenantDBRouting enables "one tenant, one database" routing. + TenantDBRouting bool `json:"tenant_db_routing,omitempty"` } func (a *adapter) getContext() (context.Context, context.CancelFunc) { @@ -94,6 +108,44 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) { return context.Background(), nil } +func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) { + if strings.TrimSpace(dsn) != "" { + cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://")) + if err != nil { + return nil, "", err + } + if dbName != "" { + cfg.DBName = dbName + } + dsn = cfg.FormatDSN() + dbName = cfg.DBName + } else { + cfg := config.Config + if dbName != "" { + cfg.DBName = dbName + } + if cfg.DBName == "" { + cfg.DBName = defaultDatabase + } + dsn = cfg.FormatDSN() + dbName = cfg.DBName + } + db, err := sqlx.Open("mysql", dsn) + if err != nil { + return nil, "", err + } + if config.MaxOpenConns > 0 { + db.SetMaxOpenConns(config.MaxOpenConns) + } + if config.MaxIdleConns > 0 { + db.SetMaxIdleConns(config.MaxIdleConns) + } + if config.ConnMaxLifetime > 0 { + db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second) + } + return db, dbName, nil +} + // Open initializes database session func (a *adapter) Open(jsonconfig json.RawMessage) error { if a.db != nil { @@ -139,6 +191,11 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { if a.dbName == "" { a.dbName = defaultDatabase } + config.Config.DBName = a.dbName + a.baseConfig = &config + a.tenantDBRouting = config.TenantDBRouting + a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB) + a.tenantAdapters = make(map[t.TenantID]*adapter) if a.maxResults <= 0 { a.maxResults = defaultMaxResults @@ -149,7 +206,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { } // This just initializes the driver but does not open the network connection. - a.db, err = sqlx.Open("mysql", a.dsn) + a.db, a.dbName, err = openMySQLDB(config, a.dsn, a.dbName) if err != nil { return err } @@ -162,20 +219,20 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { err = nil } if err == nil { - if config.MaxOpenConns > 0 { - a.db.SetMaxOpenConns(config.MaxOpenConns) - } - if config.MaxIdleConns > 0 { - a.db.SetMaxIdleConns(config.MaxIdleConns) - } - if config.ConnMaxLifetime > 0 { - a.db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second) - } if config.SqlTimeout > 0 { a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second // We allocate txTimeoutMultiplier times sqlTimeout for transactions. a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second } + if a.tenantRegistryDB != "" && a.tenantRegistryDB != a.dbName { + a.registryDB, _, err = openMySQLDB(config, a.dsn, a.tenantRegistryDB) + if err == nil { + err = a.registryDB.Ping() + } + if err != nil { + return errors.New("mysql adapter failed to open tenant registry database: " + err.Error()) + } + } } return err } @@ -183,8 +240,26 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { // Close closes the underlying database connection func (a *adapter) Close() error { var err error + a.tenantMu.Lock() + for tenantID, tenantAdp := range a.tenantAdapters { + if tenantAdp != nil && tenantAdp.db != nil { + if closeErr := tenantAdp.db.Close(); err == nil { + err = closeErr + } + } + delete(a.tenantAdapters, tenantID) + } + a.tenantMu.Unlock() + if a.registryDB != nil { + if closeErr := a.registryDB.Close(); err == nil { + err = closeErr + } + a.registryDB = nil + } if a.db != nil { - err = a.db.Close() + if closeErr := a.db.Close(); err == nil { + err = closeErr + } a.db = nil a.version = -1 } @@ -285,18 +360,112 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) { } var tenant t.Tenant - err := a.db.GetContext(ctx, &tenant, - `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by + db := a.db + if a.registryDB != nil { + db = a.registryDB + } + err := db.GetContext(ctx, &tenant, + `SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn, + state, created_at, created_by, updated_at, updated_by FROM im_tenant WHERE code=? LIMIT 1`, code) if err == sql.ErrNoRows { return nil, t.ErrNotFound } + if isUnknownColumn(err) { + err = db.GetContext(ctx, &tenant, + `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by + FROM im_tenant WHERE code=? LIMIT 1`, code) + } if err != nil { return nil, err } return &tenant, nil } +func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) { + if !a.tenantDBRouting || tenantID == defaultTenantID { + return a, a, nil + } + a.tenantMu.Lock() + if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil { + a.tenantMu.Unlock() + return tenantAdp, tenantAdp, nil + } + a.tenantMu.Unlock() + + tenant, err := a.tenantByID(tenantID) + if err != nil { + return nil, nil, err + } + dbName := strings.TrimSpace(tenant.DBName) + dsn := strings.TrimSpace(tenant.DBDSN) + if dbName == "" && dsn == "" { + return a, a, nil + } + if a.baseConfig == nil { + return nil, nil, t.ErrFailed + } + db, resolvedDBName, err := openMySQLDB(*a.baseConfig, mysqlDefaultString(dsn, a.dsn), dbName) + if err != nil { + return nil, nil, err + } + if err = db.Ping(); err != nil { + db.Close() + return nil, nil, err + } + tenantAdp := &adapter{ + db: db, + dsn: mysqlDefaultString(dsn, a.dsn), + dbName: resolvedDBName, + maxResults: a.maxResults, + maxMessageResults: a.maxMessageResults, + version: a.version, + sqlTimeout: a.sqlTimeout, + txTimeout: a.txTimeout, + baseConfig: a.baseConfig, + } + a.tenantMu.Lock() + if existing := a.tenantAdapters[tenantID]; existing != nil { + a.tenantMu.Unlock() + db.Close() + return existing, existing, nil + } + a.tenantAdapters[tenantID] = tenantAdp + a.tenantMu.Unlock() + return tenantAdp, tenantAdp, nil +} + +func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) { + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + db := a.db + if a.registryDB != nil { + db = a.registryDB + } + var tenant t.Tenant + err := db.GetContext(ctx, &tenant, + `SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn, + state, created_at, created_by, updated_at, updated_by + FROM im_tenant WHERE id=? LIMIT 1`, tenantID) + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + if isUnknownColumn(err) { + err = db.GetContext(ctx, &tenant, + `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by + FROM im_tenant WHERE id=? LIMIT 1`, tenantID) + } + if err != nil { + return nil, err + } + if !tenant.IsActive() { + return nil, t.ErrPermissionDenied + } + return &tenant, nil +} + // CreateDb initializes the storage. func (a *adapter) CreateDb(reset bool) error { var err error @@ -348,6 +517,8 @@ func (a *adapter) CreateDb(reset bool) error { code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', state SMALLINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL, @@ -407,8 +578,8 @@ func (a *adapter) CreateDb(reset bool) error { } if _, err = tx.Exec( - `INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by) - VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil { + `INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) + VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil { return err } @@ -997,10 +1168,12 @@ func (a *adapter) UpgradeDb() error { if _, err := a.db.Exec( `CREATE TABLE im_tenant( id BIGINT NOT NULL AUTO_INCREMENT, - code VARCHAR(64) NOT NULL, - name VARCHAR(128) NOT NULL, - tenant_desc VARCHAR(256), - state SMALLINT NOT NULL, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', + state SMALLINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), @@ -1030,8 +1203,8 @@ func (a *adapter) UpgradeDb() error { } if _, err := a.db.Exec( - `INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by) - VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil { + `INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) + VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil { return err } @@ -1162,6 +1335,10 @@ func (a *adapter) UpgradeDb() error { } } + if err := ensureTenantRoutingColumns(a); 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)) @@ -1191,6 +1368,20 @@ func createIMNotificationTables(tx *sql.Tx) error { return err } +func ensureTenantRoutingColumns(a *adapter) error { + statements := []string{ + "ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc", + "ALTER TABLE im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name", + "UPDATE im_tenant SET db_name='tinode' WHERE code='default' AND db_name=''", + } + for _, stmt := range statements { + if _, err := a.db.Exec(stmt); err != nil && !isDuplicateColumn(err) { + return err + } + } + return nil +} + func upgradeIMMessageActions(a *adapter) error { statements := []string{ "ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id", @@ -1272,6 +1463,29 @@ func createIMRelationTables(exec sqlExecutor) error { INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE )`, + `CREATE TABLE im_favorites( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + favorite_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + item_type VARCHAR(16) NOT NULL, + source_topic CHAR(25) NOT NULL, + source_seq INT NOT NULL, + snapshot JSON, + attachment_refs JSON, + availability VARCHAR(16) NOT NULL DEFAULT 'available', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + collected_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id), + UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq), + UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key), + INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC), + INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC), + CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`, } for _, stmt := range statements { if _, err := exec.Exec(stmt); err != nil { @@ -4608,6 +4822,29 @@ func isMissingDb(err error) bool { return ok && myerr.Number == 1049 } +func isUnknownColumn(err error) bool { + if err == nil { + return false + } + myerr, ok := err.(*ms.MySQLError) + return ok && myerr.Number == 1054 +} + +func isDuplicateColumn(err error) bool { + if err == nil { + return false + } + myerr, ok := err.(*ms.MySQLError) + return ok && myerr.Number == 1060 +} + +func mysqlDefaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} + // GetTestAdapter returns an adapter object. Useful for running tests. func GetTestAdapter() *adapter { return &adapter{} diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql index a8377e8..068dbe6 100644 --- a/server/db/mysql/schema.sql +++ b/server/db/mysql/schema.sql @@ -19,6 +19,8 @@ CREATE TABLE im_tenant( code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', state SMALLINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL, @@ -72,8 +74,8 @@ CREATE TABLE im_verification_codes( CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ); -INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by) -VALUES('default','Default Tenant',NULL,1,0,0); +INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) +VALUES('default','Default Tenant',NULL,'tinode','',1,0,0); CREATE TABLE kvmeta( @@ -246,6 +248,31 @@ CREATE TABLE im_user_blocks( CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE ); +CREATE TABLE im_favorites( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + favorite_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + item_type VARCHAR(16) NOT NULL, + source_topic CHAR(25) NOT NULL, + source_seq INT NOT NULL, + snapshot JSON, + attachment_refs JSON, + availability VARCHAR(16) NOT NULL DEFAULT 'available', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + collected_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + + PRIMARY KEY(id), + UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id), + UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq), + UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key), + INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC), + INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC), + CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +); + # Indexed user tags. CREATE TABLE usertags( id INT NOT NULL AUTO_INCREMENT, diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 67e757b..d18f1f8 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -1549,6 +1549,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op return msgs, rows.Err() } +func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) { + if err := validTenant(id); err != nil || topic == "" || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"m.tenant_id=?", "m.topic=?", "m.delid=0"} + args := []any{id, topic} + if cursor != "" { + parsed, err := strconv.Atoi(cursor) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "m.seqid 100 { + limit = 50 + } + where := []string{"tenant_id=?", "user_id=?"} + args := []any{id, decodeUID(uid)} + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "id0 AND INSTR(s.modegiven,'R')>0 + ) + OR EXISTS ( + SELECT 1 FROM filemsglinks AS fml + INNER JOIN messages AS m + ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid + INNER JOIN subscriptions AS s + ON s.tenant_id=m.tenant_id AND s.topic=m.topic AND s.userid=? + WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.msgid IS NOT NULL + AND m.deletedat IS NULL AND m.recalled_at IS NULL + AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 + AND NOT EXISTS ( + SELECT 1 FROM dellog AS d + WHERE d.tenant_id=m.tenant_id AND d.topic=m.topic AND d.deletedfor=? + AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid) + ) + ) + ) LIMIT 1`, + id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid)) + if err != nil { + return false, err + } + return allowed > 0, nil +} + func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) { if err := validTenant(id); err != nil { return "", err @@ -2998,6 +3252,26 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F }, nil } +func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) { + if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() || len([]rune(remark)) > 64 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + res, err := a.db.ExecContext(ctx, + "UPDATE im_friendships SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?", + remark, now, id, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend) + if err != nil { + return nil, err + } + if affected, err := res.RowsAffected(); err == nil && affected == 0 { + return nil, t.ErrNotFound + } + return a.TenantFriendshipGet(id, userID, peerID) +} + func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, now time.Time) error { if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() { return t.ErrMalformed @@ -3044,6 +3318,79 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) }, nil } +func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) { + if err := validTenant(id); err != nil || blockerID.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + where := []string{"b.tenant_id=?", "b.blocker_id=?"} + args := []any{id, decodeUID(blockerID)} + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "b.blocked_id 50 { + limit = 20 + } + where := []string{"u.tenant_id=?", "u.id<>?", "u.state=?"} + args := []any{id, decodeUID(viewer), t.StateOK} + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "u.id>?") + args = append(args, parsed) + } + join := "" + switch searchType { + case "", "account": + like := "%" + queryText + "%" + join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id" + where = append(where, "(ut.tag LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)") + args = append(args, like, like, userIDNumericArg(queryText)) + case "exact_account": + join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id" + where = append(where, "(ut.tag=? OR u.id=?)") + args = append(args, queryText, userIDNumericArg(queryText)) + case "phone": + where = append(where, `EXISTS ( + SELECT 1 FROM credentials AS c + WHERE c.tenant_id=u.tenant_id AND c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true + )`) + args = append(args, queryText) + default: + return nil, "", t.ErrMalformed + } + args = append(args, limit+1) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* FROM users AS u `+join+` + WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + users := make([]t.User, 0, limit) + nextCursor := "" + for rows.Next() { + var user t.User + if err = rows.StructScan(&user); err != nil { + return nil, "", err + } + scanTenantUser(&user) + if len(users) == limit { + nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10) + continue + } + users = append(users, user) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return users, nextCursor, nil +} + +func userIDNumericArg(value string) int64 { + if uid := t.ParseUserId(value); !uid.IsZero() { + return decodeUID(uid) + } + return 0 +} + func (a *adapter) friendRequestByRequestID(ctx context.Context, tenantID t.TenantID, requestID string) (*t.FriendRequest, error) { return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID) } diff --git a/server/hdl_files.go b/server/hdl_files.go index cf8838b..0c4463f 100644 --- a/server/hdl_files.go +++ b/server/hdl_files.go @@ -18,6 +18,7 @@ import ( "mime" "net/http" "net/url" + "path/filepath" "strconv" "strings" "time" @@ -131,12 +132,6 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) { return } - if req.Method == http.MethodHead { - wrt.WriteHeader(http.StatusOK) - logs.Info.Println("media serve: completed", req.Method, "uid=", uid) - return - } - fd, rsc, err := mh.Download(tenantID, req.URL.String()) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) @@ -145,7 +140,15 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) { defer rsc.Close() + if err = authorizeLargeFileDownload(tenantID, uid, req.URL.String(), fd); err != nil { + writeHttpResponse(decodeStoreError(err, "", now, nil), err) + return + } + wrt.Header().Set("Content-Type", fd.MimeType) + if fd.ETag != "" { + wrt.Header().Set("ETag", strconv.Quote(fd.ETag)) + } asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt")) // Force download for html files as a security measure. asAttachment = asAttachment || @@ -159,7 +162,7 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) { strings.HasPrefix(fd.MimeType, "multipart/") || strings.HasPrefix(fd.MimeType, "text/") if asAttachment { - wrt.Header().Set("Content-Disposition", "attachment") + wrt.Header().Set("Content-Disposition", contentDispositionAttachment(fd)) } http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc) @@ -415,6 +418,11 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg defer rsc.Close() + if err = authorizeLargeFileDownload(tenantID, uid, req.GetUri(), fd); err != nil { + writeResponse(decodeStoreError(err, msgID, now, nil), err) + return nil + } + resp.Code = http.StatusOK resp.Text = http.StatusText(http.StatusOK) resp.Meta.Name = fd.Location @@ -445,6 +453,46 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg return err } +func authorizeLargeFileDownload(tenantID types.TenantID, uid types.Uid, rawURL string, fd *types.FileDef) error { + if fd == nil { + return types.ErrNotFound + } + fid := fd.Uid() + if fid.IsZero() && rawURL != "" && store.Store != nil { + fid = store.Store.GetMediaHandler().GetIdFromUrl(rawURL) + } + if fid.IsZero() || fd.TenantID != tenantID || fd.Status != types.UploadCompleted { + return types.ErrNotFound + } + allowed, err := store.Files.UserHasAccess(tenantID, fid.String(), uid) + if err != nil { + return err + } + if !allowed { + return types.ErrPermissionDenied + } + return nil +} + +func contentDispositionAttachment(fd *types.FileDef) string { + name := strings.TrimSpace(filepath.Base(fd.Location)) + if name == "." || name == "/" || name == string(filepath.Separator) { + name = fd.Uid().String() + } + clean := strings.Map(func(r rune) rune { + switch { + case r < 32 || r == 127 || r == '"' || r == '\\' || r == '/' || r == ':': + return -1 + default: + return r + } + }, name) + if clean == "" { + clean = fd.Uid().String() + } + return mime.FormatMediaType("attachment", map[string]string{"filename": clean}) +} + // LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP. func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error { now := types.TimeNow() diff --git a/server/hdl_files_test.go b/server/hdl_files_test.go new file mode 100644 index 0000000..7df810e --- /dev/null +++ b/server/hdl_files_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/mock_store" + "github.com/tinode/chat/server/store/types" +) + +func TestAuthorizeLargeFileDownloadDenied(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldFiles := store.Files + files := mock_store.NewMockFilePersistenceInterface(ctrl) + store.Files = files + defer func() { store.Files = oldFiles }() + + fd := &types.FileDef{ + ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}, + TenantID: 7, + Status: types.UploadCompleted, + MimeType: "text/plain", + Location: "notes.txt", + } + files.EXPECT().UserHasAccess(types.TenantID(7), types.Uid(100).String(), types.Uid(42)).Return(false, nil) + if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrPermissionDenied { + t.Fatalf("expected permission denied, got %v", err) + } +} + +func TestContentDispositionAttachmentSanitizesName(t *testing.T) { + fd := &types.FileDef{ + ObjHeader: types.ObjHeader{Id: types.Uid(100).String()}, + Location: `../bad"name.txt`, + } + got := contentDispositionAttachment(fd) + if got != `attachment; filename=badname.txt` { + t.Fatalf("unexpected content disposition: %s", got) + } +} diff --git a/server/im_http.go b/server/im_http.go index 88b0802..6d673d4 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1,6 +1,7 @@ package main import ( + "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" @@ -9,7 +10,9 @@ import ( "fmt" "io" "math/big" + "net" "net/http" + "net/url" "regexp" "strconv" "strings" @@ -48,6 +51,9 @@ const ( imActionTokenExpiresInSeconds = 300 imIndependentPasswordMaxAttempts = 5 imIndependentPasswordLockSeconds = 300 + imQRCodeDefaultExpiresInSeconds = 86400 + imQRCodeMaxExpiresInSeconds = 7 * 86400 + groupMaxAdminCount = 10 ) type imHTTPResponse struct { @@ -92,6 +98,11 @@ type imSecurityChallengeRequest struct { Channel string `json:"channel"` } +type imURLCheckRequest struct { + URL string `json:"url"` + Source string `json:"source,omitempty"` +} + type imRealNameSubmissionRequest struct { Name string `json:"name"` IDType string `json:"id_type"` @@ -127,6 +138,23 @@ type imMessageRevisionRequest struct { Reason string `json:"reason,omitempty"` } +type imFavoritesBatchCreateRequest struct { + Messages []struct { + Topic string `json:"topic"` + Seq int `json:"seq,string"` + } `json:"messages"` +} + +type imFavoritesBatchDeleteRequest struct { + FavoriteIDs []string `json:"favorite_ids"` +} + +type imFavoritesForwardRequest struct { + FavoriteIDs []string `json:"favorite_ids"` + TargetTopics []string `json:"target_topics"` + Comment string `json:"comment,omitempty"` +} + type imFriendRequestCreateRequest struct { TargetUserID string `json:"target_user_id"` Message string `json:"message,omitempty"` @@ -134,6 +162,108 @@ type imFriendRequestCreateRequest struct { SourceTopic string `json:"source_topic,omitempty"` } +type imQRCodeCreateRequest struct { + Purpose string `json:"purpose"` + ExpiresInSeconds int `json:"expires_in_seconds,omitempty"` +} + +type imQRCodeResolveRequest struct { + Token string `json:"token"` + Purpose string `json:"purpose,omitempty"` +} + +type imQRCodeClaims struct { + Version int `json:"v"` + TenantID types.TenantID `json:"tid"` + UserID string `json:"uid"` + Topic string `json:"topic,omitempty"` + Purpose string `json:"pur"` + Expires int64 `json:"exp"` + Nonce string `json:"non"` +} + +type imGroupQRCodeCreateRequest struct { + ExpiresInSeconds int `json:"expires_in_seconds,omitempty"` +} + +type imFriendUpdateRequest struct { + Remark string `json:"remark"` +} + +type imBlockBatchDeleteRequest struct { + UserIDs []string `json:"user_ids"` +} + +type imGroupCreateRequest struct { + Name string `json:"name"` + MemberUserIDs []string `json:"member_user_ids,omitempty"` + AvatarFileID string `json:"avatar_file_id,omitempty"` +} + +type imGroupPatchRequest struct { + Name *string `json:"name,omitempty"` + AvatarFileID *string `json:"avatar_file_id,omitempty"` + Description *string `json:"description,omitempty"` + Version int `json:"version,omitempty"` +} + +type imGroupMemberSettingsRequest struct { + Pinned *bool `json:"pinned,omitempty"` + MuteNotifications *bool `json:"mute_notifications,omitempty"` +} + +type imGroupMemberProfileUpdateRequest struct { + Nickname string `json:"nickname"` + Version int `json:"version,omitempty"` +} + +type imGroupInvitationCreateRequest struct { + UserIDs []string `json:"user_ids"` + Message string `json:"message,omitempty"` +} + +type imGroupJoinRequestCreateRequest struct { + Message string `json:"message,omitempty"` + QRToken string `json:"qr_token,omitempty"` + Source string `json:"source,omitempty"` +} + +type imGroupJoinRequestActionRequest struct { + Version int `json:"version,omitempty"` +} + +type imGroupAnnouncementPutRequest struct { + Content string `json:"content"` + Version int `json:"version,omitempty"` +} + +type imGroupGovernancePatchRequest struct { + JoinApproval *bool `json:"join_approval,omitempty"` + MuteAll *bool `json:"mute_all,omitempty"` + MemberUploadFiles *bool `json:"member_upload_files,omitempty"` + MemberPrivateChat *bool `json:"member_private_chat,omitempty"` + MemberInvite *bool `json:"member_invite,omitempty"` + Version int `json:"version,omitempty"` +} + +type imGroupAdminsBatchRequest struct { + UserIDs []string `json:"user_ids"` + Version int `json:"version,omitempty"` +} + +type imGroupOwnerTransferRequest struct { + TargetUserID string `json:"target_user_id"` + Version int `json:"version,omitempty"` +} + +type imGroupMuteBatchRequest struct { + Members []struct { + UserID string `json:"user_id"` + ExpiresAt string `json:"expires_at,omitempty"` + } `json:"members"` + Reason string `json:"reason,omitempty"` +} + func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -516,6 +646,32 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) } +func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + if _, ok := authenticateIMHTTPBearer(wrt, req); !ok { + return + } + var body imURLCheckRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + decision, riskLevel, reason := classifyIMURLRisk(strings.TrimSpace(body.URL)) + payload := map[string]any{ + "url": strings.TrimSpace(body.URL), + "source": strings.TrimSpace(body.Source), + "decision": decision, + "risk_level": riskLevel, + "reason": reason, + } + if decision != "allow" { + payload["risk_challenge_id"] = "url_" + shortTokenHash(strings.ToLower(strings.TrimSpace(body.URL))) + } + writeIMSuccess(wrt, req, payload) +} + func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -816,8 +972,226 @@ func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)}) } +func serveIMFavorites(wrt http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + serveIMFavoriteList(wrt, req) + case http.MethodPost: + serveIMFavoriteBatchCreate(wrt, req) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMFavoriteList(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.Favorites.List(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + payload = append(payload, favoritePayload(item)) + } + writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) +} + +func serveIMFavoriteBatchCreate(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imFavoritesBatchCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if len(body.Messages) == 0 || len(body.Messages) > 100 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "messages"}) + return + } + now := time.Now().UTC().Round(time.Millisecond) + created, existing := 0, 0 + failed := make([]map[string]any, 0) + for _, src := range body.Messages { + if src.Topic == "" || src.Seq <= 0 { + failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": imReasonMalformedRequest}) + continue + } + msg, err := store.Messages.GetOne(principal.TenantID, src.Topic, src.Seq) + if err != nil { + failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)}) + continue + } + fav := &types.FavoriteItem{ + TenantID: principal.TenantID, + FavoriteID: "fav_" + store.Store.GetUidString(), + UserID: principal.Uid, + ItemType: imMessageType(msg), + SourceTopic: src.Topic, + SourceSeq: src.Seq, + Snapshot: favoriteSnapshot(msg), + AttachmentRefs: favoriteAttachmentRefs(msg), + Availability: "available", + IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")), + CollectedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + item, err := store.Favorites.Create(principal.TenantID, fav) + if err != nil { + failed = append(failed, map[string]any{"topic": src.Topic, "seq": src.Seq, "reason": relationErrorReason(err)}) + continue + } + if item.FavoriteID == fav.FavoriteID { + created++ + } else { + existing++ + } + } + writeIMSuccess(wrt, req, map[string]any{"created_count": created, "existing_count": existing, "failed": failed}) +} + +func serveIMFavoriteResource(wrt http.ResponseWriter, req *http.Request) { + const prefix = "/v1/im/favorites/" + idx := strings.Index(req.URL.Path, prefix) + if idx < 0 { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "favorite route not found", nil) + return + } + tail := strings.Trim(strings.TrimPrefix(req.URL.Path[idx:], prefix), "/") + switch { + case tail == "batch-delete": + serveIMFavoriteBatchDelete(wrt, req) + case tail == "forward": + serveIMFavoriteForward(wrt, req) + default: + serveIMFavoriteDetail(wrt, req, tail) + } +} + +func serveIMFavoriteDetail(wrt http.ResponseWriter, req *http.Request, favoriteID string) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, favoritePayload(*item)) + case http.MethodDelete: + if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"favorite_id": favoriteID, "deleted": true}) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMFavoriteBatchDelete(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 imFavoritesBatchDeleteRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + results := make([]map[string]any, 0, len(body.FavoriteIDs)) + for _, favoriteID := range body.FavoriteIDs { + favoriteID = strings.TrimSpace(favoriteID) + item := map[string]any{"favorite_id": favoriteID} + if err := store.Favorites.Delete(principal.TenantID, principal.Uid, favoriteID); err != nil { + item["ok"] = false + item["reason"] = relationErrorReason(err) + } else { + item["ok"] = true + } + results = append(results, item) + } + writeIMSuccess(wrt, req, map[string]any{"items": results}) +} + +func serveIMFavoriteForward(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 imFavoritesForwardRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if len(body.FavoriteIDs) == 0 || len(body.FavoriteIDs) > 100 || len(body.TargetTopics) == 0 || len(body.TargetTopics) > 20 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + results := make([]map[string]any, 0, len(body.TargetTopics)*len(body.FavoriteIDs)) + comment := strings.TrimSpace(body.Comment) + for _, target := range body.TargetTopics { + target = strings.TrimSpace(target) + if target == "" { + results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": imReasonMalformedRequest}) + continue + } + if err := requireTopicWriter(principal, target); err != nil { + results = append(results, map[string]any{"target_topic": target, "status": "failed", "reason": relationErrorReason(err)}) + continue + } + for _, favoriteID := range body.FavoriteIDs { + favoriteID = strings.TrimSpace(favoriteID) + item, err := store.Favorites.Get(principal.TenantID, principal.Uid, favoriteID) + if err != nil { + results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) + continue + } + msg := &types.Message{ + TenantID: principal.TenantID, + Topic: target, + From: principal.Uid.String(), + Head: types.KVMap{ + "type": item.ItemType, + "x-im-type": item.ItemType, + "x-im-forwarded": true, + }, + Content: favoriteForwardContent(item, comment), + } + saved, err := store.Messages.Save(principal.TenantID, msg, nil, true) + if err != nil { + results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)}) + continue + } + results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId}) + } + } + writeIMSuccess(wrt, req, map[string]any{"items": results}) +} + func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { switch { + case strings.HasSuffix(req.URL.Path, "/messages/search"): + serveIMMessageSearch(wrt, req) + case strings.Contains(req.URL.Path, "/messages/") && strings.HasSuffix(req.URL.Path, "/context"): + serveIMMessageContext(wrt, req) + case strings.HasSuffix(req.URL.Path, "/media"): + serveIMTopicMedia(wrt, req) case strings.HasSuffix(req.URL.Path, "/receipts"): serveIMMessageReceipts(wrt, req) case strings.HasSuffix(req.URL.Path, "/recall"): @@ -829,6 +1203,82 @@ func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) { } } +func serveIMMessageSearch(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMessagesSearchPath(req.URL.Path)) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), strings.TrimSpace(req.URL.Query().Get("type")), strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items := make([]map[string]any, 0, len(msgs)) + for _, msg := range msgs { + items = append(items, messageSearchPayload(msg, strings.TrimSpace(req.URL.Query().Get("q")))) + } + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor}) +} + +func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + topic, seq, ok := messageContextPath(req.URL.Path) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message context route not found", nil) + return + } + principal, _, ok := authenticateTopicReader(wrt, req, topic) + if !ok { + return + } + before := parseIMLimit(req.URL.Query().Get("before"), 20, 100) + after := parseIMLimit(req.URL.Query().Get("after"), 20, 100) + msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1}) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items := make([]map[string]any, 0, len(msgs)) + for _, msg := range msgs { + items = append(items, messageContextPayload(msg)) + } + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "anchor_seq": seq, "items": items}) +} + +func serveIMTopicMedia(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, topic, ok := authenticateTopicReader(wrt, req, topicFromMediaPath(req.URL.Path)) + if !ok { + return + } + mediaType := strings.TrimSpace(req.URL.Query().Get("type")) + if mediaType == "" { + mediaType = "image" + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + msgs, nextCursor, err := store.Messages.Search(principal.TenantID, topic, principal.Uid, strings.TrimSpace(req.URL.Query().Get("q")), mediaType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items := make([]map[string]any, 0, len(msgs)) + for _, msg := range msgs { + items = append(items, mediaMessagePayload(msg)) + } + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": nextCursor}) +} + func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -1115,16 +1565,217 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } +func serveIMGroups(wrt http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + serveIMGroupList(wrt, req) + case http.MethodPost: + serveIMGroupCreate(wrt, req) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + category := strings.TrimSpace(req.URL.Query().Get("category")) + switch category { + case "", "owned", "managed", "joined": + default: + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "category"}) + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor")) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) + return + } + query := strings.TrimSpace(req.URL.Query().Get("query")) + subs, err := store.Users.GetSubs(principal.TenantID, principal.Uid) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + items := make([]map[string]any, 0, limit) + seen := 0 + nextCursor := "" + for _, sub := range subs { + if types.GetTopicCat(sub.Topic) != types.TopicCatGrp { + continue + } + item := groupListItemPayload(sub) + if category != "" && groupCategory(groupMemberRole(sub.ModeGiven&sub.ModeWant)) != category { + continue + } + if query != "" && !groupListItemMatches(item, query) { + continue + } + if seen < cursor { + seen++ + continue + } + if len(items) == limit { + nextCursor = strconv.Itoa(seen) + break + } + items = append(items, item) + seen++ + } + writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""}) +} + +func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imGroupCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + name := strings.TrimSpace(body.Name) + if len([]rune(name)) < 1 || len([]rune(name)) > 32 || len(body.MemberUserIDs) > 99 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + topicName := genTopicName() + now := time.Now().UTC().Round(time.Millisecond) + public := map[string]any{"name": name, "version": 1} + if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" { + public["avatar_file_id"] = avatar + } + topic := &types.Topic{ + ObjHeader: types.ObjHeader{Id: topicName}, + TenantID: principal.TenantID, + State: types.StateOK, + Access: types.DefaultAccess{Auth: types.ModeCPublic, Anon: types.ModeNone}, + Public: public, + } + topic.GiveAccess(principal.Uid, types.ModeCFull, types.ModeCFull) + if err := store.Topics.Create(principal.TenantID, topic, principal.Uid, groupMemberPrivate("", false, false, 1, now)); err != nil { + writeIMRelationError(wrt, req, err) + return + } + var members []*types.Subscription + seen := map[types.Uid]bool{principal.Uid: true} + for _, rawID := range body.MemberUserIDs { + uid := types.ParseUserId(strings.TrimSpace(rawID)) + if uid.IsZero() || seen[uid] { + continue + } + seen[uid] = true + members = append(members, &types.Subscription{ + TenantID: principal.TenantID, + User: uid.String(), + Topic: topicName, + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + Private: groupMemberPrivate("", false, false, 1, now), + }) + } + if len(members) > 0 { + if err := store.Subs.Create(principal.TenantID, members...); err != nil { + writeIMRelationError(wrt, req, err) + return + } + _ = store.Topics.UpdateSubCnt(principal.TenantID, topicName) + } + created, err := store.Topics.Get(principal.TenantID, topicName) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + selfSub, _ := store.Subs.Get(principal.TenantID, topicName, principal.Uid, false) + writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub)) +} + func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { topic, tail, ok := groupResourcePath(req.URL.Path) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) return } + if tail == "settings" { + serveIMGroupSettings(wrt, req, topic) + return + } + if tail == "member-settings" { + serveIMGroupMemberSettings(wrt, req, topic) + return + } + if tail == "members/me/profile" { + serveIMGroupMyProfile(wrt, req, topic) + return + } + if tail == "leave" { + serveIMGroupLeave(wrt, req, topic) + return + } + if tail == "qrcodes" { + serveIMGroupQRCodes(wrt, req, topic) + return + } + if tail == "invitations" { + serveIMGroupInvitations(wrt, req, topic) + return + } + if tail == "join-requests" { + serveIMGroupJoinRequests(wrt, req, topic) + return + } + if strings.HasPrefix(tail, "join-requests/") { + serveIMGroupJoinRequestAction(wrt, req, topic, tail) + return + } + if tail == "summary" { + serveIMGroupSummary(wrt, req, topic) + return + } + if tail == "announcements/latest" || tail == "announcements/current" || strings.HasPrefix(tail, "announcements/") { + serveIMGroupAnnouncements(wrt, req, topic, tail) + return + } + if tail == "governance" { + serveIMGroupGovernance(wrt, req, topic) + return + } + if tail == "role-limits" { + serveIMGroupRoleLimits(wrt, req, topic) + return + } + if tail == "admins/batch" || strings.HasPrefix(tail, "admins/") { + serveIMGroupAdmins(wrt, req, topic, tail) + return + } + if tail == "owner-transfer" { + serveIMGroupOwnerTransfer(wrt, req, topic) + return + } + if tail == "mutes" || strings.HasPrefix(tail, "mutes/") { + serveIMGroupMutes(wrt, req, topic, tail) + return + } if tail == "members" { serveIMGroupMembers(wrt, req, topic) return } + if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") { + memberID, ok := groupMemberProfilePath(tail) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) + return + } + serveIMGroupMemberProfile(wrt, req, topic, memberID) + return + } + if tail == "" || !strings.Contains(tail, "/") { + serveIMGroupDetail(wrt, req, topic) + return + } writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) } @@ -1162,6 +1813,9 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin } query := strings.TrimSpace(req.URL.Query().Get("query")) mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable")) + roleFilter := strings.TrimSpace(req.URL.Query().Get("role")) + eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role")) + muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible")) members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -1173,10 +1827,28 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin for _, member := range members { access := member.ModeGiven & member.ModeWant mentionable := access.IsJoiner() && access.IsReader() + role := groupMemberRole(access) + if roleFilter != "" && role != roleFilter { + continue + } if mentionableOnly && !mentionable { continue } item := groupMemberPayload(member, mentionable) + if eligibleRole != "" { + eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole) + item["eligible"] = eligible + if reason != "" { + item["ineligible_reason"] = reason + } + } + if muteEligible { + eligible, reason := groupMuteEligibility(selfAccess, access) + item["eligible"] = eligible + if reason != "" { + item["ineligible_reason"] = reason + } + } if query != "" && !groupMemberMatches(item, query) { continue } @@ -1202,6 +1874,971 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin }) } +func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if group == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) + return + } + writeIMSuccess(wrt, req, groupDetailPayload(group, selfSub)) + case http.MethodPatch: + access := selfSub.ModeGiven & selfSub.ModeWant + if !access.IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + var body imGroupPatchRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if group == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) + return + } + public := groupPublicMap(group.Public) + currentVersion := intFromAny(public["version"]) + if currentVersion <= 0 { + currentVersion = 1 + } + if body.Version > 0 && body.Version != currentVersion { + writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) + return + } + if body.Name != nil { + name := strings.TrimSpace(*body.Name) + if len([]rune(name)) < 1 || len([]rune(name)) > 32 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "name"}) + return + } + public["name"] = name + } + if body.AvatarFileID != nil { + public["avatar_file_id"] = strings.TrimSpace(*body.AvatarFileID) + } + if body.Description != nil { + description := strings.TrimSpace(*body.Description) + if len([]rune(description)) > 200 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"}) + return + } + public["description"] = description + } + public["version"] = currentVersion + 1 + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Public": public}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + updated, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, groupDetailPayload(updated, selfSub)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, groupSettingsPayload(group, selfSub)) +} + +func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodPatch { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + var body imGroupMemberSettingsRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + private := groupPrivateMap(selfSub.Private) + if body.Pinned != nil { + private["pinned"] = *body.Pinned + } + if body.MuteNotifications != nil { + private["mute_notifications"] = *body.MuteNotifications + } + private["version"] = intFromAny(private["version"]) + 1 + private["updated_at"] = time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano) + if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + selfSub.Private = private + writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub)) +} + +func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodPut { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + var body imGroupMemberProfileUpdateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + nickname := strings.TrimSpace(body.Nickname) + if len([]rune(nickname)) > 32 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "nickname"}) + return + } + private := groupPrivateMap(selfSub.Private) + currentVersion := intFromAny(private["profile_version"]) + if currentVersion <= 0 { + currentVersion = 1 + } + if body.Version > 0 && body.Version != currentVersion { + writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) + return + } + private["nickname"] = nickname + private["profile_version"] = currentVersion + 1 + updatedAt := time.Now().UTC().Round(time.Millisecond) + private["profile_updated_at"] = updatedAt.Format(time.RFC3339Nano) + if err := store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"Private": private}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)}) +} + +func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if (selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "owner must transfer or dismiss group before leaving", nil) + return + } + if err := store.Subs.Delete(principal.TenantID, topic, principal.Uid); err != nil { + writeIMRelationError(wrt, req, err) + return + } + _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"}) +} + +func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if !(selfSub.ModeGiven & selfSub.ModeWant).IsSharer() && !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + var body imGroupQRCodeCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + expiresIn := body.ExpiresInSeconds + if expiresIn <= 0 { + expiresIn = imQRCodeDefaultExpiresInSeconds + } + if expiresIn > imQRCodeMaxExpiresInSeconds { + expiresIn = imQRCodeMaxExpiresInSeconds + } + expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond) + token, err := generateIMQRCodeTokenForTopic(principal.TenantID, principal.Uid, topic, "join_group", expiresAt) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "qr_token": token, + "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group", + "expires_at": expiresAt.Format(time.RFC3339Nano), + "group_snapshot": groupDetailPayload(group, selfSub), + }) +} + +func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + access := selfSub.ModeGiven & selfSub.ModeWant + if !access.IsSharer() && !access.IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + var body imGroupInvitationCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"}) + return + } + results := make([]map[string]any, 0, len(body.UserIDs)) + for _, rawID := range body.UserIDs { + uid := types.ParseUserId(strings.TrimSpace(rawID)) + item := map[string]any{"user_id": strings.TrimSpace(rawID)} + if uid.IsZero() || uid == principal.Uid { + item["status"] = "failed" + item["reason"] = imReasonMalformedRequest + results = append(results, item) + continue + } + item["user_id"] = uid.UserId() + if _, err := store.Subs.Get(principal.TenantID, topic, uid, false); err == nil { + item["status"] = "already_member" + results = append(results, item) + continue + } + if err := store.Subs.Create(principal.TenantID, &types.Subscription{ + TenantID: principal.TenantID, + User: uid.String(), + Topic: topic, + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)), + }); err != nil { + item["status"] = "failed" + item["reason"] = relationErrorReason(err) + } else { + item["status"] = "joined" + } + results = append(results, item) + } + _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": results}) +} + +func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, req.Method == http.MethodGet) + if !ok { + return + } + switch req.Method { + case http.MethodGet: + if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + status := strings.TrimSpace(req.URL.Query().Get("status")) + if status == "" { + status = "pending" + } + items := filterGroupJoinRequests(groupJoinRequests(group), status) + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": ""}) + case http.MethodPost: + var body imGroupJoinRequestCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + aux := groupAuxMap(group.Aux) + requests := groupJoinRequests(group) + now := time.Now().UTC().Round(time.Millisecond) + requestID := "gjr_" + store.Store.GetUidString() + item := map[string]any{ + "request_id": requestID, + "user_id": principal.Uid.UserId(), + "inviter_id": "", + "source": defaultString(strings.TrimSpace(body.Source), "manual"), + "message": strings.TrimSpace(body.Message), + "status": "pending", + "version": 1, + "created_at": now.Format(time.RFC3339Nano), + "updated_at": now.Format(time.RFC3339Nano), + "qr_token": strings.TrimSpace(body.QRToken), + } + requests = append(requests, item) + aux["join_requests"] = requests + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, item) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, topic, tail string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + requestID, action, ok := groupJoinRequestActionPath(tail) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request route not found", nil) + return + } + var body imGroupJoinRequestActionRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + aux := groupAuxMap(group.Aux) + requests := groupJoinRequests(group) + updated, found := resolveGroupJoinRequest(requests, requestID, action, principal.Uid, body.Version) + if !found { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request not found", nil) + return + } + aux["join_requests"] = requests + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + if action == "accept" { + uid := types.ParseUserId(stringFromAny(updated["user_id"])) + if !uid.IsZero() { + _ = store.Subs.Create(principal.TenantID, &types.Subscription{ + TenantID: principal.TenantID, + User: uid.String(), + Topic: topic, + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)), + }) + _ = store.Topics.UpdateSubCnt(principal.TenantID, topic) + } + } + writeIMSuccess(wrt, req, updated) +} + +func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + announcement := latestGroupAnnouncement(group) + writeIMSuccess(wrt, req, map[string]any{ + "topic": topic, + "pending_join_request_count": len(filterGroupJoinRequests(groupJoinRequests(group), "pending")), + "announcement": announcement, + "permissions": groupPermissions(selfSub.ModeGiven & selfSub.ModeWant), + }) +} + +func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic, tail string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + switch { + case tail == "announcements/latest" && req.Method == http.MethodGet: + writeIMSuccess(wrt, req, latestGroupAnnouncement(group)) + case tail == "announcements/current" && req.Method == http.MethodPut: + if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + var body imGroupAnnouncementPutRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + content := strings.TrimSpace(body.Content) + if content == "" || len([]rune(content)) > 2000 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"}) + return + } + aux := groupAuxMap(group.Aux) + current := latestGroupAnnouncement(group) + version := intFromAny(current["version"]) + if version <= 0 { + version = 1 + } + if body.Version > 0 && body.Version != version { + writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": version}) + return + } + now := time.Now().UTC().Round(time.Millisecond) + announcement := map[string]any{ + "announcement_id": defaultString(stringFromAny(current["announcement_id"]), "ann_"+store.Store.GetUidString()), + "content": content, + "author": principal.Uid.UserId(), + "version": version + 1, + "published_at": now.Format(time.RFC3339Nano), + } + aux["announcement"] = announcement + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, announcement) + case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost: + announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read") + now := time.Now().UTC().Round(time.Millisecond) + writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)}) + case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet: + writeIMSuccess(wrt, req, latestGroupAnnouncement(group)) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + switch req.Method { + case http.MethodGet: + writeIMSuccess(wrt, req, groupGovernancePayload(group, selfSub)) + case http.MethodPatch: + if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + var body imGroupGovernancePatchRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + aux := groupAuxMap(group.Aux) + governance := groupGovernanceMap(aux["governance"]) + currentVersion := intFromAny(governance["version"]) + if currentVersion <= 0 { + currentVersion = 1 + } + if body.Version > 0 && body.Version != currentVersion { + writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion}) + return + } + applyBoolPatch(governance, "join_approval", body.JoinApproval) + applyBoolPatch(governance, "mute_all", body.MuteAll) + applyBoolPatch(governance, "member_upload_files", body.MemberUploadFiles) + applyBoolPatch(governance, "member_private_chat", body.MemberPrivateChat) + applyBoolPatch(governance, "member_invite", body.MemberInvite) + governance["version"] = currentVersion + 1 + aux["governance"] = governance + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, governance) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupRoleLimits(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{"max_admin_count": groupMaxAdminCount, "current_admin_count": countGroupAdmins(members)}) +} + +func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + switch { + case tail == "admins/batch" && req.Method == http.MethodPost: + var body imGroupAdminsBatchRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if len(body.UserIDs) == 0 || len(body.UserIDs) > groupMaxAdminCount { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"}) + return + } + members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if countGroupAdmins(members)+len(body.UserIDs) > groupMaxAdminCount { + writeIMError(wrt, req, http.StatusConflict, 30006, "GROUP_ADMIN_LIMIT_EXCEEDED", "group admin limit exceeded", map[string]any{"max": groupMaxAdminCount, "current": countGroupAdmins(members), "requested": len(body.UserIDs)}) + return + } + results := make([]map[string]any, 0, len(body.UserIDs)) + for _, rawID := range body.UserIDs { + uid := types.ParseUserId(strings.TrimSpace(rawID)) + item := map[string]any{"user_id": strings.TrimSpace(rawID)} + if uid.IsZero() || uid == principal.Uid { + item["ok"] = false + item["reason"] = imReasonMalformedRequest + results = append(results, item) + continue + } + item["user_id"] = uid.UserId() + if err := store.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil { + item["ok"] = false + item["reason"] = relationErrorReason(err) + } else { + item["ok"] = true + } + results = append(results, item) + } + members, _ = store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + writeIMSuccess(wrt, req, map[string]any{"succeeded": results, "admins": groupAdminItems(members), "version": body.Version + 1, "updated_at": time.Now().UTC().Format(time.RFC3339Nano)}) + case strings.HasPrefix(tail, "admins/") && req.Method == http.MethodDelete: + userID := strings.TrimPrefix(tail, "admins/") + uid := types.ParseUserId(strings.TrimSpace(userID)) + if uid.IsZero() || uid == principal.Uid { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) + return + } + if err := store.Subs.Update(principal.TenantID, topic, uid, map[string]any{"ModeGiven": types.ModeCPublic}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)}) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if !(selfSub.ModeGiven & selfSub.ModeWant).IsOwner() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + var body imGroupOwnerTransferRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + target := types.ParseUserId(strings.TrimSpace(body.TargetUserID)) + if target.IsZero() || target == principal.Uid { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_user_id"}) + return + } + targetSub, err := store.Subs.Get(principal.TenantID, topic, target, false) + if err != nil || targetSub == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil) + return + } + if err = store.Subs.Update(principal.TenantID, topic, principal.Uid, map[string]any{"ModeGiven": types.ModeCAdmin | types.ModeCPublic}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + if err = store.Subs.Update(principal.TenantID, topic, target, map[string]any{"ModeGiven": types.ModeCFull}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + _ = store.Topics.OwnerChange(principal.TenantID, topic, target) + writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1}) +} + +func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail string) { + principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) + if !ok { + return + } + if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + group, err := store.Topics.Get(principal.TenantID, topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + aux := groupAuxMap(group.Aux) + switch { + case tail == "mutes" && req.Method == http.MethodGet: + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMuteItems(aux), "next_cursor": ""}) + case tail == "mutes" && req.Method == http.MethodPost: + var body imGroupMuteBatchRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if len(body.Members) == 0 || len(body.Members) > 100 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"}) + return + } + mutes := groupMuteItems(aux) + now := time.Now().UTC().Round(time.Millisecond) + for _, member := range body.Members { + uid := types.ParseUserId(strings.TrimSpace(member.UserID)) + if uid.IsZero() { + continue + } + mutes = upsertGroupMute(mutes, map[string]any{"user_id": uid.UserId(), "muted_by": principal.Uid.UserId(), "muted_at": now.Format(time.RFC3339Nano), "expires_at": strings.TrimSpace(member.ExpiresAt), "reason": strings.TrimSpace(body.Reason)}) + } + aux["mutes"] = mutes + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": mutes}) + case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete: + uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/"))) + if uid.IsZero() { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) + return + } + aux["mutes"] = removeGroupMute(groupMuteItems(aux), uid.UserId()) + if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "muted": false}) + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMGroupMemberProfile(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) { + 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 + } + if types.GetTopicCat(topic) != types.TopicCatGrp || memberID.IsZero() { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + selfSub, 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 selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) + return + } + memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil) + return + } + user, err := store.Users.Get(principal.TenantID, memberID) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, memberID, principal.Uid.P2PName(memberID)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, groupMemberProfilePayload(topic, memberSub, user, ctx)) +} + +func serveIMMeQRCodes(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 imQRCodeCreateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + purpose := strings.TrimSpace(body.Purpose) + if purpose == "" { + purpose = "add_friend" + } + if purpose != "add_friend" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"}) + return + } + expiresIn := body.ExpiresInSeconds + if expiresIn <= 0 { + expiresIn = imQRCodeDefaultExpiresInSeconds + } + if expiresIn > imQRCodeMaxExpiresInSeconds { + expiresIn = imQRCodeMaxExpiresInSeconds + } + expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second).Round(time.Millisecond) + token, err := generateIMQRCodeToken(principal.TenantID, principal.Uid, purpose, expiresAt) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + user, err := store.Users.Get(principal.TenantID, principal.Uid) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + data := map[string]any{ + "qr_token": token, + "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=" + url.QueryEscape(purpose), + "expires_at": expiresAt.Format(time.RFC3339Nano), + } + if user != nil { + data["display_profile"] = userSummaryPayload(user, "") + } + writeIMSuccess(wrt, req, data) +} + +func serveIMQRCodesResolve(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 imQRCodeResolveRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + claims, err := parseIMQRCodeToken(strings.TrimSpace(body.Token), strings.TrimSpace(body.Purpose), time.Now().UTC()) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "qr code invalid", nil) + return + } + if claims.TenantID != principal.TenantID { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) + return + } + if claims.Purpose == "join_group" { + if types.GetTopicCat(claims.Topic) != types.TopicCatGrp { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"}) + return + } + group, err := store.Topics.Get(principal.TenantID, claims.Topic) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if group == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) + return + } + selfSub, _ := store.Subs.Get(principal.TenantID, claims.Topic, principal.Uid, false) + status := "can_request" + if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() { + status = "joined" + } + writeIMSuccess(wrt, req, map[string]any{ + "purpose": claims.Purpose, + "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), + "group": groupDetailPayload(group, selfSub), + "status": status, + "source": "qr", + }) + return + } + peer := types.ParseUserId(claims.UserID) + if peer.IsZero() || peer == principal.Uid { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "token"}) + return + } + user, err := store.Users.Get(principal.TenantID, peer) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if user == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) + return + } + ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "purpose": claims.Purpose, + "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), + "user": userSearchItemPayload(types.UserSearchItem{User: user, Relationship: ctx.Relationship, RequestID: ctx.RequestID, CanAdd: ctx.Relationship == types.RelationshipNone, Action: imRelationshipAction(ctx.Relationship)}), + "source": "qr", + "source_topic": "", + }) +} + +func serveIMUserSearch(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 + } + searchType := strings.TrimSpace(req.URL.Query().Get("type")) + query := strings.TrimSpace(req.URL.Query().Get("q")) + if searchType == "phone" { + if phone, err := normalizeIMPhone(query, "CN"); err == nil { + query = phone + } + } + if query == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 50) + items, nextCursor, err := store.Relations.SearchUsers(principal.TenantID, principal.Uid, query, searchType, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + payload = append(payload, userSearchItemPayload(item)) + } + writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) +} + +func serveIMUserResource(wrt http.ResponseWriter, req *http.Request) { + userID, tail, ok := userResourcePath(req.URL.Path) + if !ok || tail != "profile" { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user route not found", nil) + return + } + serveIMUserProfile(wrt, req, userID) +} + +func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, userID types.Uid) { + 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 + } + if userID.IsZero() || userID == principal.Uid { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) + return + } + user, err := store.Users.Get(principal.TenantID, userID) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + if user == nil { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "user not found", nil) + return + } + ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, userID, principal.Uid.P2PName(userID)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, userProfilePayload(user, ctx)) +} + func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -1231,6 +2868,10 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { } func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) { + if strings.TrimSuffix(req.URL.Path, "/") == "/v1/im/blocks/batch-delete" { + serveIMBlocksBatchDelete(wrt, req) + return + } principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return @@ -1263,11 +2904,68 @@ func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, relationshipContextPayload(ctx)) } -func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodDelete { +func serveIMBlocks(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"), 50, 100) + items, nextCursor, err := store.Relations.ListBlocks(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + payload = append(payload, blockListItemPayload(item)) + } + writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) +} + +func serveIMBlocksBatchDelete(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 imBlockBatchDeleteRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if len(body.UserIDs) == 0 || len(body.UserIDs) > 100 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_ids"}) + return + } + results := make([]map[string]any, 0, len(body.UserIDs)) + for _, rawID := range body.UserIDs { + peer := types.ParseUserId(strings.TrimSpace(rawID)) + item := map[string]any{"user_id": strings.TrimSpace(rawID)} + if peer.IsZero() || peer == principal.Uid { + item["ok"] = false + item["reason"] = imReasonMalformedRequest + results = append(results, item) + continue + } + item["user_id"] = peer.UserId() + if err := store.Relations.UnblockUser(principal.TenantID, principal.Uid, peer); err != nil { + item["ok"] = false + item["reason"] = relationErrorReason(err) + } else { + item["ok"] = true + } + results = append(results, item) + } + writeIMSuccess(wrt, req, map[string]any{"items": results}) +} + +func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return @@ -1277,8 +2975,24 @@ func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } - if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, time.Now().UTC().Round(time.Millisecond)); err != nil { - writeIMRelationError(wrt, req, err) + now := time.Now().UTC().Round(time.Millisecond) + switch req.Method { + case http.MethodDelete: + if err := store.Relations.DeleteFriend(principal.TenantID, principal.Uid, peer, now); err != nil { + writeIMRelationError(wrt, req, err) + return + } + case http.MethodPatch: + var body imFriendUpdateRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if _, err := store.Relations.UpdateFriendRemark(principal.TenantID, principal.Uid, peer, strings.TrimSpace(body.Remark), now); err != nil { + writeIMRelationError(wrt, req, err) + return + } + default: + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } ctx, err := store.Relations.GetRelationshipContext(principal.TenantID, principal.Uid, peer, principal.Uid.P2PName(peer)) @@ -1668,11 +3382,93 @@ func receiptMemberPayload(sub types.Subscription) map[string]any { } } +func groupListItemPayload(sub types.Subscription) map[string]any { + public := groupPublicMap(sub.GetPublic()) + access := sub.ModeGiven & sub.ModeWant + return map[string]any{ + "topic": sub.Topic, + "name": groupName(public, sub.Topic), + "avatar_file_id": stringFromAny(public["avatar_file_id"]), + "member_count": sub.GetSubCnt(), + "my_role": groupMemberRole(access), + "updated_at": sub.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { + public := map[string]any{} + topic := "" + memberCount := 0 + ownerID := "" + if group != nil { + public = groupPublicMap(group.Public) + topic = group.Id + memberCount = group.SubCnt + if owner := types.ParseUid(group.Owner); !owner.IsZero() { + ownerID = owner.UserId() + } + } + version := intFromAny(public["version"]) + if version <= 0 { + version = 1 + } + access := types.ModeNone + if selfSub != nil { + access = selfSub.ModeGiven & selfSub.ModeWant + if topic == "" { + topic = selfSub.Topic + } + } + return map[string]any{ + "topic": topic, + "name": groupName(public, topic), + "avatar_file_id": stringFromAny(public["avatar_file_id"]), + "description": stringFromAny(public["description"]), + "owner": ownerID, + "member_count": memberCount, + "version": version, + "my_role": groupMemberRole(access), + "permissions": groupPermissions(access), + } +} + +func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { + data := groupDetailPayload(group, selfSub) + private := map[string]any{} + if selfSub != nil { + private = groupPrivateMap(selfSub.Private) + } + data["pinned"] = boolFromAny(private["pinned"]) + data["mute_notifications"] = boolFromAny(private["mute_notifications"]) + data["message_retention"] = stringFromAny(private["message_retention"]) + if data["message_retention"] == "" { + data["message_retention"] = "forever" + } + data["governance"] = defaultGroupGovernance() + return data +} + +func groupMemberSettingsPayload(sub *types.Subscription) map[string]any { + private := map[string]any{} + if sub != nil { + private = groupPrivateMap(sub.Private) + } + return map[string]any{ + "pinned": boolFromAny(private["pinned"]), + "mute_notifications": boolFromAny(private["mute_notifications"]), + "version": intFromAny(private["version"]), + "updated_at": stringFromAny(private["updated_at"]), + } +} + func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any { access := sub.ModeGiven & sub.ModeWant userID := types.ParseUserId(sub.User) user := &types.User{ObjHeader: types.ObjHeader{Id: userID.String()}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()} data := userSummaryPayload(user, "") + if private := groupPrivateMap(sub.Private); stringFromAny(private["nickname"]) != "" { + data["group_nickname"] = stringFromAny(private["nickname"]) + } data["role"] = groupMemberRole(access) data["mentionable"] = mentionable data["acs"] = map[string]any{ @@ -1683,6 +3479,33 @@ func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any return data } +func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any { + data := groupMemberPayload(*sub, true) + data["topic"] = topic + data["relationship"] = ctx.Relationship + data["capabilities"] = map[string]any{ + "can_send": ctx.CanSend, + "can_call": ctx.CanSend, + "can_add_friend": ctx.Relationship == types.RelationshipNone, + } + if ctx.RequestID != "" { + data["request_id"] = ctx.RequestID + } + if user != nil { + for key, value := range userSummaryPayload(user, "") { + if key == "avatar_file_id" || key == "display_name" { + data[key] = value + } + } + if public, ok := user.Public.(map[string]any); ok { + copyOptionalProfileFields(data, public) + } else if public, ok := user.Public.(types.KVMap); ok { + copyOptionalProfileFields(data, map[string]any(public)) + } + } + return data +} + func groupMemberRole(access types.AccessMode) string { switch { case access.IsOwner(): @@ -1694,6 +3517,276 @@ func groupMemberRole(access types.AccessMode) string { } } +func groupCategory(role string) string { + switch role { + case "owner": + return "owned" + case "admin": + return "managed" + default: + return "joined" + } +} + +func groupPermissions(access types.AccessMode) map[string]any { + return map[string]any{ + "can_edit_profile": access.IsAdmin(), + "can_invite_members": access.IsSharer() || access.IsAdmin(), + "can_manage_members": access.IsAdmin(), + "can_manage_settings": access.IsAdmin(), + "can_leave": !access.IsOwner() && access.IsJoiner(), + } +} + +func defaultGroupGovernance() map[string]any { + return map[string]any{ + "join_approval": false, + "mute_all": false, + "member_upload_files": true, + "member_private_chat": true, + "member_invite": true, + "version": 1, + } +} + +func groupGovernanceMap(value any) map[string]any { + governance := defaultGroupGovernance() + for key, value := range groupPrivateMap(value) { + governance[key] = value + } + return governance +} + +func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map[string]any { + aux := map[string]any{} + if group != nil { + aux = groupAuxMap(group.Aux) + } + governance := groupGovernanceMap(aux["governance"]) + editable := false + if selfSub != nil { + editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() + } + for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} { + governance[key+"_editable"] = editable + } + return governance +} + +func groupAuxMap(value any) map[string]any { + switch aux := value.(type) { + case map[string]any: + return copyStringAnyMap(aux) + case types.KVMap: + return copyStringAnyMap(map[string]any(aux)) + default: + return map[string]any{} + } +} + +func groupPublicMap(value any) map[string]any { + switch public := value.(type) { + case map[string]any: + return copyStringAnyMap(public) + case types.KVMap: + return copyStringAnyMap(map[string]any(public)) + default: + return map[string]any{} + } +} + +func groupJoinRequests(group *types.Topic) []map[string]any { + if group == nil { + return nil + } + return sliceOfMaps(groupAuxMap(group.Aux)["join_requests"]) +} + +func filterGroupJoinRequests(items []map[string]any, status string) []map[string]any { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if status == "" || stringFromAny(item["status"]) == status { + out = append(out, item) + } + } + return out +} + +func resolveGroupJoinRequest(items []map[string]any, requestID, action string, actor types.Uid, version int) (map[string]any, bool) { + now := time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano) + for i := range items { + if stringFromAny(items[i]["request_id"]) != requestID { + continue + } + currentVersion := intFromAny(items[i]["version"]) + if version > 0 && currentVersion > 0 && version != currentVersion { + items[i]["status"] = "conflict" + return items[i], true + } + switch action { + case "accept": + items[i]["status"] = "accepted" + case "reject": + items[i]["status"] = "rejected" + default: + return nil, false + } + items[i]["handled_by"] = actor.UserId() + items[i]["handled_at"] = now + items[i]["updated_at"] = now + items[i]["version"] = currentVersion + 1 + return items[i], true + } + return nil, false +} + +func latestGroupAnnouncement(group *types.Topic) map[string]any { + if group == nil { + return map[string]any{} + } + return groupPrivateMap(groupAuxMap(group.Aux)["announcement"]) +} + +func groupMuteItems(aux map[string]any) []map[string]any { + return sliceOfMaps(aux["mutes"]) +} + +func upsertGroupMute(items []map[string]any, item map[string]any) []map[string]any { + userID := stringFromAny(item["user_id"]) + for i := range items { + if stringFromAny(items[i]["user_id"]) == userID { + items[i] = item + return items + } + } + return append(items, item) +} + +func removeGroupMute(items []map[string]any, userID string) []map[string]any { + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if stringFromAny(item["user_id"]) != userID { + out = append(out, item) + } + } + return out +} + +func sliceOfMaps(value any) []map[string]any { + raw, ok := value.([]any) + if !ok { + if typed, ok := value.([]map[string]any); ok { + return typed + } + return nil + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]any); ok { + out = append(out, copyStringAnyMap(m)) + } + } + return out +} + +func applyBoolPatch(target map[string]any, key string, value *bool) { + if value != nil { + target[key] = *value + } +} + +func groupRoleEligibility(actorAccess, memberAccess types.AccessMode, role string) (bool, string) { + if !actorAccess.IsOwner() { + return false, "owner_required" + } + switch role { + case "admin": + if memberAccess.IsOwner() { + return false, "owner" + } + if memberAccess.IsAdmin() { + return false, "already_admin" + } + return true, "" + case "owner": + if memberAccess.IsOwner() { + return false, "current_owner" + } + return memberAccess.IsJoiner(), "" + default: + return false, "unsupported_role" + } +} + +func groupMuteEligibility(actorAccess, memberAccess types.AccessMode) (bool, string) { + if !actorAccess.IsAdmin() { + return false, "admin_required" + } + if memberAccess.IsOwner() { + return false, "owner" + } + return memberAccess.IsJoiner(), "" +} + +func countGroupAdmins(members []types.Subscription) int { + count := 0 + for _, member := range members { + access := member.ModeGiven & member.ModeWant + if access.IsAdmin() && !access.IsOwner() { + count++ + } + } + return count +} + +func groupAdminItems(members []types.Subscription) []map[string]any { + items := make([]map[string]any, 0) + for _, member := range members { + access := member.ModeGiven & member.ModeWant + if access.IsAdmin() && !access.IsOwner() { + items = append(items, groupMemberPayload(member, true)) + } + } + return items +} + +func groupPrivateMap(value any) map[string]any { + switch private := value.(type) { + case map[string]any: + return copyStringAnyMap(private) + case types.KVMap: + return copyStringAnyMap(map[string]any(private)) + default: + return map[string]any{} + } +} + +func groupMemberPrivate(nickname string, pinned, mute bool, version int, updatedAt time.Time) map[string]any { + return map[string]any{ + "nickname": nickname, + "pinned": pinned, + "mute_notifications": mute, + "version": version, + "profile_version": 1, + "updated_at": updatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func groupName(public map[string]any, fallback string) string { + for _, key := range []string{"name", "display_name", "fn"} { + value := strings.TrimSpace(fmt.Sprint(public[key])) + if value != "" && value != "" { + return value + } + } + return fallback +} + +func groupListItemMatches(item map[string]any, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + return query == "" || strings.Contains(strings.ToLower(fmt.Sprint(item["topic"])), query) || strings.Contains(strings.ToLower(fmt.Sprint(item["name"])), query) +} + func groupMemberMatches(item map[string]any, query string) bool { query = strings.ToLower(strings.TrimSpace(query)) if query == "" { @@ -1779,6 +3872,59 @@ func messageMutationPayload(msg *types.Message, status string) map[string]any { return data } +func favoritePayload(item types.FavoriteItem) map[string]any { + return map[string]any{ + "favorite_id": item.FavoriteID, + "type": item.ItemType, + "snapshot": item.Snapshot, + "source": map[string]any{"topic": item.SourceTopic, "seq": item.SourceSeq}, + "collected_at": item.CollectedAt.UTC().Format(time.RFC3339Nano), + "availability": item.Availability, + } +} + +func messageSearchPayload(msg types.Message, query string) map[string]any { + text := messageText(msg) + return map[string]any{ + "seq": msg.SeqId, + "from": msg.From, + "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), + "type": imMessageType(&msg), + "snippet": textSnippet(text, query), + "matches": textMatches(text, query), + "snapshot": favoriteSnapshot(&msg), + "availability": messageAvailability(msg), + } +} + +func messageContextPayload(msg types.Message) map[string]any { + return map[string]any{ + "seq": msg.SeqId, + "from": msg.From, + "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), + "type": imMessageType(&msg), + "head": msg.Head, + "content": msg.Content, + "availability": messageAvailability(msg), + } +} + +func mediaMessagePayload(msg types.Message) map[string]any { + snapshot := favoriteSnapshot(&msg) + data := map[string]any{ + "seq": msg.SeqId, + "from": msg.From, + "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), + "availability": messageAvailability(msg), + } + if content, ok := snapshot["content"].(map[string]any); ok { + for key, value := range content { + data[key] = value + } + } + return data +} + func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any { data := map[string]any{ "topic": ctx.Topic, @@ -1867,6 +4013,70 @@ func friendListItemPayload(item types.FriendListItem) map[string]any { return data } +func userSearchItemPayload(item types.UserSearchItem) map[string]any { + data := userSummaryPayload(item.User, "") + data["relationship"] = item.Relationship + data["can_add"] = item.CanAdd + data["action"] = item.Action + if item.RequestID != "" { + data["request_id"] = item.RequestID + } + return data +} + +func imRelationshipAction(relationship string) string { + switch relationship { + case types.RelationshipFriend: + return "chat" + case types.RelationshipPendingInbound, types.RelationshipPendingOutbound: + return "pending" + case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer: + return "none" + default: + return "add" + } +} + +func blockListItemPayload(item types.BlockListItem) map[string]any { + data := map[string]any{ + "user_id": item.Block.BlockedID.UserId(), + "blocked_at": item.Block.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": item.Block.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if item.User != nil { + data["user"] = userSummaryPayload(item.User, "") + } + return data +} + +func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[string]any { + data := userSummaryPayload(user, "") + data["relationship"] = ctx.Relationship + data["topic"] = ctx.Topic + data["capabilities"] = map[string]any{ + "can_send": ctx.CanSend, + "can_call": ctx.CanSend, + "can_add_friend": ctx.Relationship == types.RelationshipNone, + } + if ctx.RequestID != "" { + data["request_id"] = ctx.RequestID + } + if public, ok := user.Public.(map[string]any); ok { + copyOptionalProfileFields(data, public) + } else if public, ok := user.Public.(types.KVMap); ok { + copyOptionalProfileFields(data, map[string]any(public)) + } + return data +} + +func copyOptionalProfileFields(data map[string]any, public map[string]any) { + for _, key := range []string{"gender", "region", "public_account", "signature"} { + if value, ok := public[key]; ok { + data[key] = value + } + } +} + func userSummaryPayload(user *types.User, remark string) map[string]any { uid := types.ParseUid(user.Id) userID := uid.UserId() @@ -1927,6 +4137,300 @@ func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) } } +func relationErrorReason(err error) string { + switch { + case errors.Is(err, types.ErrNotFound): + return "NOT_FOUND" + case errors.Is(err, types.ErrPermissionDenied): + return imReasonPermissionDenied + case errors.Is(err, types.ErrMalformed): + return imReasonMalformedRequest + default: + return imReasonInternal + } +} + +func requireIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, needRead bool) (*imPrincipal, *types.Subscription, bool) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return nil, nil, false + } + if types.GetTopicCat(topic) != types.TopicCatGrp { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return nil, nil, false + } + 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 nil, nil, false + } + access := types.ModeNone + if sub != nil { + access = sub.ModeGiven & sub.ModeWant + } + if sub == nil || !access.IsJoiner() || (needRead && !access.IsReader()) { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil) + return nil, nil, false + } + return principal, sub, true +} + +func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic string) (*imPrincipal, string, bool) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return nil, "", false + } + if topic == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return nil, "", false + } + 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 nil, "", false + } + if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) + return nil, "", false + } + return principal, topic, true +} + +func requireTopicWriter(principal *imPrincipal, topic string) error { + if principal == nil || topic == "" { + return types.ErrMalformed + } + sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) + if err != nil { + return err + } + if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsWriter() { + return types.ErrPermissionDenied + } + return nil +} + +func copyStringAnyMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func stringFromAny(value any) string { + if value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} + +func boolFromAny(value any) bool { + if got, ok := value.(bool); ok { + return got + } + if got, ok := value.(string); ok { + return got == "true" || got == "1" + } + return false +} + +func intFromAny(value any) int { + switch got := value.(type) { + case int: + return got + case int64: + return int(got) + case float64: + return int(got) + case json.Number: + parsed, _ := got.Int64() + return int(parsed) + case string: + parsed, _ := strconv.Atoi(got) + return parsed + default: + return 0 + } +} + +func imMessageType(msg *types.Message) string { + if msg == nil { + return "text" + } + for _, key := range []string{"type", "x-im-type"} { + if value := strings.TrimSpace(fmt.Sprint(msg.Head[key])); value != "" && value != "" { + return value + } + } + if content, ok := msg.Content.(map[string]any); ok { + if fileID := stringFromAny(content["file_id"]); fileID != "" { + mime := stringFromAny(content["mime"]) + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "video/"): + return "video" + case strings.HasPrefix(mime, "audio/"): + return "voice" + default: + return "file" + } + } + if stringFromAny(content["url"]) != "" { + return "link" + } + } + return "text" +} + +func favoriteSnapshot(msg *types.Message) map[string]any { + if msg == nil { + return map[string]any{} + } + return map[string]any{ + "topic": msg.Topic, + "seq": msg.SeqId, + "from": msg.From, + "ts": msg.CreatedAt.UTC().Format(time.RFC3339Nano), + "type": imMessageType(msg), + "head": msg.Head, + "content": msg.Content, + "text": messageText(*msg), + } +} + +func favoriteAttachmentRefs(msg *types.Message) any { + if msg == nil { + return []string{} + } + if content, ok := msg.Content.(map[string]any); ok { + if fileID := stringFromAny(content["file_id"]); fileID != "" { + return []string{fileID} + } + } + return []string{} +} + +func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string]any { + payload := map[string]any{ + "favorite_id": item.FavoriteID, + "source": map[string]any{ + "topic": item.SourceTopic, + "seq": item.SourceSeq, + }, + "snapshot": item.Snapshot, + "forwarded": true, + } + if refs, ok := item.AttachmentRefs.([]string); ok && len(refs) > 0 { + payload["attachment_refs"] = refs + } else if item.AttachmentRefs != nil { + payload["attachment_refs"] = item.AttachmentRefs + } + if comment != "" { + payload["comment"] = comment + } + return payload +} + +func classifyIMURLRisk(rawURL string) (decision, riskLevel, reason string) { + if rawURL == "" || len(rawURL) > 2048 { + return "block", "high", "invalid_url" + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "block", "high", "invalid_url" + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return "block", "high", "unsupported_scheme" + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") { + return "block", "high", "private_host" + } + if ip := net.ParseIP(host); ip != nil { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return "block", "high", "private_host" + } + } + normalized := strings.ToLower(rawURL) + for _, marker := range []string{"javascript:", "data:", "file:", "password=", "passwd=", "token=", "access_token=", "xn--"} { + if strings.Contains(normalized, marker) { + return "warn", "medium", "suspicious_url" + } + } + return "allow", "low", "ok" +} + +func shortTokenHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(sum[:])[:16] +} + +func messageText(msg types.Message) string { + switch content := msg.Content.(type) { + case string: + return content + case map[string]any: + for _, key := range []string{"text", "content", "title", "summary", "name", "url"} { + if value := stringFromAny(content[key]); value != "" { + return value + } + } + } + encoded, _ := json.Marshal(msg.Content) + return string(encoded) +} + +func messageAvailability(msg types.Message) string { + if msg.DeletedAt != nil || msg.RecalledAt != nil { + return "removed" + } + return "available" +} + +func textSnippet(text, query string) string { + text = strings.TrimSpace(text) + if len([]rune(text)) <= 120 || strings.TrimSpace(query) == "" { + return text + } + lower := strings.ToLower(text) + idx := strings.Index(lower, strings.ToLower(strings.TrimSpace(query))) + if idx < 0 { + idx = 0 + } + start := idx - 40 + if start < 0 { + start = 0 + } + end := start + 120 + runes := []rune(text) + if end > len(runes) { + end = len(runes) + } + return string(runes[start:end]) +} + +func textMatches(text, query string) []map[string]any { + query = strings.TrimSpace(query) + if query == "" { + return []map[string]any{} + } + idx := strings.Index(strings.ToLower(text), strings.ToLower(query)) + if idx < 0 { + return []map[string]any{} + } + return []map[string]any{{"start": idx, "end": idx + len(query)}} +} + func validIMRealName(name, idType, idNumber string) bool { if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" { return false @@ -2105,6 +4609,71 @@ func hashIMSecret(tenantID types.TenantID, parts ...string) string { return fmt.Sprintf("%x", h.Sum(nil)) } +func generateIMQRCodeToken(tenantID types.TenantID, uid types.Uid, purpose string, expiresAt time.Time) (string, error) { + return generateIMQRCodeTokenForTopic(tenantID, uid, "", purpose, expiresAt) +} + +func generateIMQRCodeTokenForTopic(tenantID types.TenantID, uid types.Uid, topic, purpose string, expiresAt time.Time) (string, error) { + nonce, err := randomIMToken(12) + if err != nil { + return "", err + } + claims := imQRCodeClaims{ + Version: 1, + TenantID: tenantID, + UserID: uid.UserId(), + Topic: topic, + Purpose: purpose, + Expires: expiresAt.Unix(), + Nonce: nonce, + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + encodedPayload := base64.RawURLEncoding.EncodeToString(payload) + mac := signIMQRCodePayload(encodedPayload) + return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac), nil +} + +func parseIMQRCodeToken(token, expectedPurpose string, now time.Time) (*imQRCodeClaims, error) { + payload, signature, ok := strings.Cut(token, ".") + if !ok || payload == "" || signature == "" { + return nil, types.ErrMalformed + } + got, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return nil, types.ErrMalformed + } + want := signIMQRCodePayload(payload) + if !hmac.Equal(got, want) { + return nil, types.ErrMalformed + } + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return nil, types.ErrMalformed + } + var claims imQRCodeClaims + if err = json.Unmarshal(raw, &claims); err != nil { + return nil, types.ErrMalformed + } + if claims.Version != 1 || claims.TenantID == 0 || claims.UserID == "" || claims.Purpose == "" || claims.Expires <= now.Unix() { + return nil, types.ErrMalformed + } + if expectedPurpose != "" && claims.Purpose != expectedPurpose { + return nil, types.ErrMalformed + } + return &claims, nil +} + +func signIMQRCodePayload(payload string) []byte { + mac := hmac.New(sha256.New, globals.apiKeySalt) + mac.Write([]byte("im:qrcode:v1")) + mac.Write([]byte{0}) + mac.Write([]byte(payload)) + return mac.Sum(nil) +} + func verificationChallengeIDFromPath(path string) string { const marker = "/v1/im/auth/verification-codes/" idx := strings.Index(path, marker) @@ -2183,6 +4752,45 @@ func messageReceiptsPath(path string) (string, int, bool) { return topic, seq, true } +func topicFromMessagesSearchPath(path string) string { + const prefix = "/v1/im/topics/" + const suffix = "/messages/search" + return topicFromPathWithSuffix(path, prefix, suffix) +} + +func topicFromMediaPath(path string) string { + const prefix = "/v1/im/topics/" + const suffix = "/media" + return topicFromPathWithSuffix(path, prefix, suffix) +} + +func topicFromPathWithSuffix(path, prefix, suffix string) string { + idx := strings.Index(path, prefix) + if idx < 0 || !strings.HasSuffix(path, suffix) { + return "" + } + rest := strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix) + return strings.Trim(rest, "/") +} + +func messageContextPath(path string) (string, int, bool) { + const prefix = "/v1/im/topics/" + idx := strings.Index(path, prefix) + if idx < 0 { + return "", 0, false + } + rest := strings.TrimPrefix(path[idx:], prefix) + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" { + return "", 0, false + } + seq, err := strconv.Atoi(parts[2]) + if err != nil || seq <= 0 { + return "", 0, false + } + return parts[0], seq, true +} + func messageActionPath(path, action string) (string, int, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) @@ -2223,12 +4831,51 @@ func groupResourcePath(path string) (string, string, bool) { } rest := strings.TrimPrefix(path[idx:], prefix) topic, tail, ok := strings.Cut(rest, "/") - if !ok || topic == "" || tail == "" { + if topic == "" { return "", "", false } + if !ok { + return topic, "", true + } return topic, strings.Trim(tail, "/"), true } +func userResourcePath(path string) (types.Uid, string, bool) { + const prefix = "/v1/im/users/" + idx := strings.Index(path, prefix) + if idx < 0 { + return types.ZeroUid, "", false + } + rest := strings.TrimPrefix(path[idx:], prefix) + userID, tail, ok := strings.Cut(rest, "/") + if !ok || userID == "" || tail == "" { + return types.ZeroUid, "", false + } + return types.ParseUserId(strings.TrimSpace(userID)), strings.Trim(tail, "/"), true +} + +func groupMemberProfilePath(tail string) (types.Uid, bool) { + parts := strings.Split(strings.Trim(tail, "/"), "/") + if len(parts) != 3 || parts[0] != "members" || parts[2] != "profile" { + return types.ZeroUid, false + } + uid := types.ParseUserId(strings.TrimSpace(parts[1])) + return uid, !uid.IsZero() +} + +func groupJoinRequestActionPath(tail string) (string, string, bool) { + parts := strings.Split(strings.Trim(tail, "/"), "/") + if len(parts) != 3 || parts[0] != "join-requests" || parts[1] == "" { + return "", "", false + } + switch parts[2] { + case "accept", "reject": + return strings.TrimSpace(parts[1]), parts[2], true + default: + return "", "", false + } +} + func friendRequestActionFromPath(path string) (string, string) { const prefix = "/v1/im/friend-requests/" idx := strings.Index(path, prefix) diff --git a/server/im_http_test.go b/server/im_http_test.go index 7f24a95..8534b04 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -42,6 +42,38 @@ type testRelationsStore struct { reason string friends []types.FriendListItem friendRequests []types.FriendRequestListItem + searchResults []types.UserSearchItem + blocks []types.BlockListItem +} + +type testFavoritesStore struct { + items []types.FavoriteItem + created *types.FavoriteItem + deleted []string +} + +func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) { + s.created = item + s.items = append(s.items, *item) + return item, nil +} + +func (s *testFavoritesStore) List(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FavoriteItem, string, error) { + return s.items, "", nil +} + +func (s *testFavoritesStore) Get(_ types.TenantID, _ types.Uid, favoriteID string) (*types.FavoriteItem, error) { + for i := range s.items { + if s.items[i].FavoriteID == favoriteID { + return &s.items[i], nil + } + } + return nil, types.ErrNotFound +} + +func (s *testFavoritesStore) Delete(_ types.TenantID, _ types.Uid, favoriteID string) error { + s.deleted = append(s.deleted, favoriteID) + return nil } func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) { @@ -107,6 +139,18 @@ func (s *testRelationsStore) DeleteFriend(_ types.TenantID, _, _ types.Uid, _ ti return nil } +func (s *testRelationsStore) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) { + return &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now}, nil +} + +func (s *testRelationsStore) SearchUsers(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.UserSearchItem, string, error) { + return s.searchResults, "", nil +} + +func (s *testRelationsStore) ListBlocks(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.BlockListItem, string, error) { + return s.blocks, "", nil +} + func (s *testVerificationStore) Create(_ types.TenantID, vc *types.VerificationCode) error { s.created = vc return s.err @@ -868,6 +912,782 @@ func TestServeIMGroupMembers(t *testing.T) { } } +func TestServeIMGroupMemberProfile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldUsers := store.Users + oldRelations := store.Relations + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + store.Subs = subs + store.Users = users + peer := types.Uid(101) + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, + UserID: 42, + PeerID: peer, + Topic: types.Uid(42).P2PName(peer), + Relationship: types.RelationshipNone, + CanSend: false, + }} + defer func() { + store.Subs = oldSubs + store.Users = oldUsers + store.Relations = oldRelations + }() + + subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpMention", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + }, nil) + member := &types.Subscription{TenantID: 7, User: peer.UserId(), Topic: "grpMention", ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} + member.SetPublic(map[string]any{"display_name": "Group Alice"}) + subs.EXPECT().Get(types.TenantID(7), "grpMention", peer, false).Return(member, nil) + users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: peer.String()}, + TenantID: 7, + Public: map[string]any{"display_name": "Alice", "signature": "hi"}, + }, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members/"+peer.UserId()+"/profile", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMGroupResource(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["relationship"] != types.RelationshipNone || data["signature"] != "hi" || data["role"] != "member" { + t.Fatalf("unexpected group member profile response: %#v", data) + } +} + +func TestServeIMGroupList(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 }() + sub := types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpAlpha", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)}, + } + sub.SetPublic(map[string]any{"name": "Alpha", "avatar_file_id": "file-1"}) + sub.SetSubCnt(3) + users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups?category=owned&query=alp", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMGroups(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 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["my_role"] != "owner" { + t.Fatalf("unexpected group list response: %#v", data) + } +} + +func TestServeIMGroupCreate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + 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-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + mockStore.EXPECT().GetUidString().Return("newgroup") + store.Store = mockStore + oldTopics := store.Topics + oldSubs := store.Subs + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Topics = topics + store.Subs = subs + defer func() { + store.Store = oldStore + store.Topics = oldTopics + store.Subs = oldSubs + }() + + topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn( + func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error { + if topic.Id != "grpnewgroup" { + t.Fatalf("unexpected topic id: %s", topic.Id) + } + return nil + }) + subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil) + topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil) + topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{ + ObjHeader: types.ObjHeader{Id: "grpnewgroup"}, + TenantID: 7, + Owner: types.Uid(42).String(), + SubCnt: 2, + Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "version": 1}, + }, nil) + subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpnewgroup", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMGroups(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"] != "grpnewgroup" || data["name"] != "New Group" { + t.Fatalf("unexpected group create response: %#v", data) + } +} + +func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) + store.Store = mockStore + oldTopics := store.Topics + oldSubs := store.Subs + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Topics = topics + store.Subs = subs + defer func() { + store.Store = oldStore + store.Topics = oldTopics + store.Subs = oldSubs + }() + selfSub := &types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpAlpha", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + Private: map[string]any{"version": 1, "profile_version": 1}, + } + group := &types.Topic{ + ObjHeader: types.ObjHeader{Id: "grpAlpha"}, + TenantID: 7, + Owner: types.Uid(42).String(), + SubCnt: 3, + Public: map[string]any{"name": "Alpha", "version": 1}, + } + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) + topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{ + ObjHeader: types.ObjHeader{Id: "grpAlpha"}, + TenantID: 7, + Owner: types.Uid(42).String(), + SubCnt: 3, + Public: map[string]any{"name": "Beta", "description": "hello", "version": 2}, + }, nil) + subs.EXPECT().Update(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return(nil).Times(2) + + req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"name":"Beta","description":"hello","version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String()) + } + if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["name"] != "Beta" || data["version"].(float64) != 2 { + t.Fatalf("unexpected patch response: %#v", data) + } + + settingsReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/member-settings", bytes.NewBufferString(`{"pinned":true,"mute_notifications":true}`)) + settingsReq.Header.Set("Authorization", "Bearer token-1") + settingsRec := httptest.NewRecorder() + serveIMGroupResource(settingsRec, settingsReq) + if settingsRec.Code != http.StatusOK { + t.Fatalf("settings status: got %d body=%s", settingsRec.Code, settingsRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, settingsRec).Data.(map[string]any); data["pinned"] != true || data["mute_notifications"] != true { + t.Fatalf("unexpected settings response: %#v", data) + } + + profileReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/members/me/profile", bytes.NewBufferString(`{"nickname":"群昵称","version":1}`)) + profileReq.Header.Set("Authorization", "Bearer token-1") + profileRec := httptest.NewRecorder() + serveIMGroupResource(profileRec, profileReq) + if profileRec.Code != http.StatusOK { + t.Fatalf("profile status: got %d body=%s", profileRec.Code, profileRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, profileRec).Data.(map[string]any); data["nickname"] != "群昵称" || data["version"].(float64) != 2 { + t.Fatalf("unexpected profile response: %#v", data) + } +} + +func TestServeIMGroupLeave(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldTopics := store.Topics + oldSubs := store.Subs + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Topics = topics + store.Subs = subs + defer func() { + store.Topics = oldTopics + store.Subs = oldSubs + }() + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpAlpha", + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + }, nil) + subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil) + topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMGroupResource(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"] != "left" || data["topic"] != "grpAlpha" { + t.Fatalf("unexpected leave response: %#v", data) + } +} + +func TestServeIMGroupQRCodesAndResolve(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")). + Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil) + store.Store = mockStore + oldTopics := store.Topics + oldSubs := store.Subs + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Topics = topics + store.Subs = subs + defer func() { + store.Store = oldStore + store.Topics = oldTopics + store.Subs = oldSubs + }() + creatorSub := &types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpAlpha", + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + } + group := &types.Topic{ + ObjHeader: types.ObjHeader{Id: "grpAlpha"}, + TenantID: 7, + Owner: types.Uid(42).String(), + SubCnt: 3, + Public: map[string]any{"name": "Alpha", "version": 1}, + } + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(creatorSub, nil) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + created := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + token := created["qr_token"].(string) + if token == "" { + t.Fatalf("unexpected group qr response: %#v", created) + } + + resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"join_group"}`)) + resolveReq.Header.Set("Authorization", "Bearer token-2") + resolveRec := httptest.NewRecorder() + + serveIMQRCodesResolve(resolveRec, resolveReq) + + if resolveRec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String()) + } + resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any) + groupPayload := resolved["group"].(map[string]any) + if resolved["status"] != "can_request" || groupPayload["name"] != "Alpha" { + t.Fatalf("unexpected group qr resolve response: %#v", resolved) + } +} + +func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) + mockStore.EXPECT().GetUidString().Return("ann-1") + mockStore.EXPECT().GetUidString().Return("join-1") + store.Store = mockStore + oldTopics := store.Topics + oldSubs := store.Subs + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Topics = topics + store.Subs = subs + defer func() { + store.Store = oldStore + store.Topics = oldTopics + store.Subs = oldSubs + }() + selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} + group := &types.Topic{ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), Aux: types.KVMap{}} + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil).Times(3) + topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil).Times(3) + + annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`)) + annReq.Header.Set("Authorization", "Bearer token-1") + annRec := httptest.NewRecorder() + serveIMGroupResource(annRec, annReq) + if annRec.Code != http.StatusOK { + t.Fatalf("announcement status: got %d body=%s", annRec.Code, annRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, annRec).Data.(map[string]any); data["content"] != "公告" { + t.Fatalf("unexpected announcement response: %#v", data) + } + + govReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/governance", bytes.NewBufferString(`{"join_approval":true,"version":1}`)) + govReq.Header.Set("Authorization", "Bearer token-1") + govRec := httptest.NewRecorder() + serveIMGroupResource(govRec, govReq) + if govRec.Code != http.StatusOK { + t.Fatalf("governance status: got %d body=%s", govRec.Code, govRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, govRec).Data.(map[string]any); data["join_approval"] != true { + t.Fatalf("unexpected governance response: %#v", data) + } + + joinReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/join-requests", bytes.NewBufferString(`{"message":"申请"}`)) + joinReq.Header.Set("Authorization", "Bearer token-1") + joinRec := httptest.NewRecorder() + serveIMGroupResource(joinRec, joinReq) + if joinRec.Code != http.StatusOK { + t.Fatalf("join request status: got %d body=%s", joinRec.Code, joinRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, joinRec).Data.(map[string]any); data["status"] != "pending" { + t.Fatalf("unexpected join request response: %#v", data) + } +} + +func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) + store.Store = mockStore + oldTopics := store.Topics + oldSubs := store.Subs + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + store.Topics = topics + store.Subs = subs + defer func() { + store.Store = oldStore + store.Topics = oldTopics + store.Subs = oldSubs + }() + owner := types.Uid(42) + target := types.Uid(43) + ownerSub := &types.Subscription{TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} + group := &types.Topic{ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: owner.String(), Aux: types.KVMap{}} + member := types.Subscription{TenantID: 7, User: target.UserId(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} + members := []types.Subscription{*ownerSub, member} + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3) + topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2) + subs.EXPECT().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2) + topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) + topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil) + subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil) + topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(nil) + + adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`)) + adminReq.Header.Set("Authorization", "Bearer token-1") + adminRec := httptest.NewRecorder() + serveIMGroupResource(adminRec, adminReq) + if adminRec.Code != http.StatusOK { + t.Fatalf("admin status: got %d body=%s", adminRec.Code, adminRec.Body.String()) + } + + muteReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes", bytes.NewBufferString(`{"members":[{"user_id":"`+target.UserId()+`"}],"reason":"test"}`)) + muteReq.Header.Set("Authorization", "Bearer token-1") + muteRec := httptest.NewRecorder() + serveIMGroupResource(muteRec, muteReq) + if muteRec.Code != http.StatusOK { + t.Fatalf("mute status: got %d body=%s", muteRec.Code, muteRec.Body.String()) + } + + transferReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/owner-transfer", bytes.NewBufferString(`{"target_user_id":"`+target.UserId()+`","version":1}`)) + transferReq.Header.Set("Authorization", "Bearer token-1") + transferRec := httptest.NewRecorder() + serveIMGroupResource(transferRec, transferReq) + if transferRec.Code != http.StatusOK { + t.Fatalf("transfer status: got %d body=%s", transferRec.Code, transferRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, transferRec).Data.(map[string]any); data["owner"] != target.UserId() { + t.Fatalf("unexpected transfer response: %#v", data) + } +} + +func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) + store.Store = mockStore + oldFavorites := store.Favorites + now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) + favorites := &testFavoritesStore{items: []types.FavoriteItem{{ + TenantID: 7, + FavoriteID: "fav_1", + UserID: 42, + ItemType: "text", + SourceTopic: "grpAlpha", + SourceSeq: 3, + Snapshot: map[string]any{"text": "hello"}, + Availability: "available", + CollectedAt: now, + }}} + store.Favorites = favorites + defer func() { + store.Store = oldStore + store.Favorites = oldFavorites + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMFavorites(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_1" { + t.Fatalf("unexpected favorites list: %#v", data) + } + + detailReq := httptest.NewRequest(http.MethodGet, "/v1/im/favorites/fav_1", nil) + detailReq.Header.Set("Authorization", "Bearer token-1") + detailRec := httptest.NewRecorder() + serveIMFavoriteResource(detailRec, detailReq) + if detailRec.Code != http.StatusOK { + t.Fatalf("detail status: got %d body=%s", detailRec.Code, detailRec.Body.String()) + } + + deleteReq := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch-delete", bytes.NewBufferString(`{"favorite_ids":["fav_1"]}`)) + deleteReq.Header.Set("Authorization", "Bearer token-1") + deleteRec := httptest.NewRecorder() + serveIMFavoriteResource(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK || len(favorites.deleted) != 1 { + t.Fatalf("delete status: got %d body=%s deleted=%#v", deleteRec.Code, deleteRec.Body.String(), favorites.deleted) + } +} + +func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + 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-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) + store.Store = mockStore + oldFavorites := store.Favorites + oldSubs := store.Subs + oldMessages := store.Messages + now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) + store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ + TenantID: 7, + FavoriteID: "fav_1", + UserID: 42, + ItemType: "text", + SourceTopic: "grpAlpha", + SourceSeq: 3, + Snapshot: map[string]any{"text": "hello"}, + Availability: "available", + CollectedAt: now, + }}} + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages + defer func() { + store.Store = oldStore + store.Favorites = oldFavorites + store.Subs = oldSubs + store.Messages = oldMessages + }() + subs.EXPECT().Get(types.TenantID(7), "grpBeta", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpBeta", + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + }, nil) + messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( + func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { + if msg.Topic != "grpBeta" || msg.From != types.Uid(42).String() { + t.Fatalf("unexpected forwarded message: %#v", msg) + } + if msg.Head["x-im-forwarded"] != true { + t.Fatalf("forward marker not set: %#v", msg.Head) + } + content := msg.Content.(map[string]any) + if content["favorite_id"] != "fav_1" || content["comment"] != "FYI" { + t.Fatalf("unexpected forward content: %#v", content) + } + return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, nil + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpBeta"],"comment":"FYI"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMFavoriteResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) + } + items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any) + if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 { + t.Fatalf("unexpected forward response: %#v", got) + } +} + +func TestServeIMSecurityURLCheck(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2) + store.Store = mockStore + defer func() { store.Store = oldStore }() + + allowReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"https://example.com/a","source":"message"}`)) + allowReq.Header.Set("Authorization", "Bearer token-1") + allowRec := httptest.NewRecorder() + serveIMSecurityURLCheck(allowRec, allowReq) + if allowRec.Code != http.StatusOK { + t.Fatalf("allow status: got %d body=%s", allowRec.Code, allowRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, allowRec).Data.(map[string]any); data["decision"] != "allow" { + t.Fatalf("unexpected allow response: %#v", data) + } + + blockReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"http://127.0.0.1/admin"}`)) + blockReq.Header.Set("Authorization", "Bearer token-1") + blockRec := httptest.NewRecorder() + serveIMSecurityURLCheck(blockRec, blockReq) + if blockRec.Code != http.StatusOK { + t.Fatalf("block status: got %d body=%s", blockRec.Code, blockRec.Body.String()) + } + if data := decodeIMHTTPResponse(t, blockRec).Data.(map[string]any); data["decision"] != "block" || data["risk_challenge_id"] == "" { + t.Fatalf("unexpected block response: %#v", data) + } +} + +func TestServeIMMessageSearchContextAndMedia(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) + store.Store = mockStore + 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.Store = oldStore + store.Subs = oldSubs + store.Messages = oldMessages + }() + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}, nil).Times(3) + textMsg := types.Message{TenantID: 7, SeqId: 3, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello world"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}} + fileMsg := types.Message{TenantID: 7, SeqId: 4, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC)}} + messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "hello", "text", "", 50).Return([]types.Message{textMsg}, "", nil) + messages.EXPECT().GetAll(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return([]types.Message{textMsg}, nil) + messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "", "image", "", 50).Return([]types.Message{fileMsg}, "", nil) + + searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/search?q=hello&type=text", nil) + searchReq.Header.Set("Authorization", "Bearer token-1") + searchRec := httptest.NewRecorder() + serveIMTopicMessageAction(searchRec, searchReq) + if searchRec.Code != http.StatusOK { + t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String()) + } + + contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/3/context", nil) + contextReq.Header.Set("Authorization", "Bearer token-1") + contextRec := httptest.NewRecorder() + serveIMTopicMessageAction(contextRec, contextReq) + if contextRec.Code != http.StatusOK { + t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String()) + } + + mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/media?type=image", nil) + mediaReq.Header.Set("Authorization", "Bearer token-1") + mediaRec := httptest.NewRecorder() + serveIMTopicMessageAction(mediaRec, mediaReq) + if mediaRec.Code != http.StatusOK { + t.Fatalf("media status: got %d body=%s", mediaRec.Code, mediaRec.Body.String()) + } +} + +func TestServeIMMeQRCodesAndResolve(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + tokenAuth := mock_auth.NewMockAuthHandler(ctrl) + mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) + tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). + Return(&auth.Rec{TenantID: 7, Uid: 1, AuthLevel: auth.LevelAuth}, nil, nil) + tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")). + Return(&auth.Rec{TenantID: 7, Uid: 2, AuthLevel: auth.LevelAuth}, nil, nil) + store.Store = mockStore + oldUsers := store.Users + oldRelations := store.Relations + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + store.Users = users + owner := types.Uid(1) + viewer := types.Uid(2) + users.EXPECT().Get(types.TenantID(7), owner).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: owner.String()}, + TenantID: 7, + Public: map[string]any{"display_name": "Owner"}, + }, nil) + users.EXPECT().Get(types.TenantID(7), owner).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: owner.String()}, + TenantID: 7, + Public: map[string]any{"display_name": "Owner"}, + }, nil) + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, + UserID: viewer, + PeerID: owner, + Topic: viewer.P2PName(owner), + Relationship: types.RelationshipNone, + CanSend: false, + }} + defer func() { + store.Store = oldStore + store.Users = oldUsers + store.Relations = oldRelations + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMMeQRCodes(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + created := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + token := created["qr_token"].(string) + if token == "" || created["qr_payload"] == "" { + t.Fatalf("unexpected qr create response: %#v", created) + } + + resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"add_friend"}`)) + resolveReq.Header.Set("Authorization", "Bearer token-2") + resolveRec := httptest.NewRecorder() + + serveIMQRCodesResolve(resolveRec, resolveReq) + + if resolveRec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String()) + } + resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any) + user := resolved["user"].(map[string]any) + if resolved["purpose"] != "add_friend" || user["action"] != "add" || user["display_name"] != "Owner" { + t.Fatalf("unexpected qr resolve response: %#v", resolved) + } +} + func TestServeIMMessageRecall(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1121,6 +1941,162 @@ func TestServeIMFriendsList(t *testing.T) { } } +func TestServeIMFriendRemark(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + peer := types.Uid(2) + store.Relations = &testRelationsStore{} + defer func() { store.Relations = oldRelations }() + + req := httptest.NewRequest(http.MethodPatch, "/v1/im/friends/"+peer.UserId(), bytes.NewBufferString(`{"remark":"小明"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMFriendResource(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["relationship"] != types.RelationshipFriend || data["topic"] == "" { + t.Fatalf("unexpected friend remark response: %#v", data) + } +} + +func TestServeIMUserSearch(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + peer := types.Uid(2) + store.Relations = &testRelationsStore{searchResults: []types.UserSearchItem{{ + User: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "avatar_file_id": "file-1"}}, + Relationship: types.RelationshipNone, + CanAdd: true, + Action: "add", + }}} + defer func() { store.Relations = oldRelations }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/users/search?q=alice&type=account", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMUserSearch(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 || items[0].(map[string]any)["relationship"] != types.RelationshipNone || items[0].(map[string]any)["can_add"] != true { + t.Fatalf("unexpected search response: %#v", data) + } +} + +func TestServeIMUserProfile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldUsers := store.Users + oldRelations := store.Relations + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + peer := types.Uid(2) + users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ + ObjHeader: types.ObjHeader{Id: peer.String()}, + TenantID: 7, + Public: map[string]any{"display_name": "Alice", "avatar_file_id": "file-1", "signature": "hello"}, + }, nil) + store.Users = users + store.Relations = &testRelationsStore{context: &types.RelationshipContext{ + TenantID: 7, + UserID: 1, + PeerID: peer, + Topic: types.Uid(1).P2PName(peer), + Relationship: types.RelationshipFriend, + CanSend: true, + }} + defer func() { + store.Users = oldUsers + store.Relations = oldRelations + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMUserResource(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["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" { + t.Fatalf("unexpected profile response: %#v", data) + } +} + +func TestServeIMBlocks(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + now := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC) + peer := types.Uid(2) + store.Relations = &testRelationsStore{blocks: []types.BlockListItem{{ + Block: types.UserBlock{ + TenantID: 7, + BlockerID: 1, + BlockedID: peer, + CreatedAt: now, + UpdatedAt: now, + }, + User: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Blocked User"}}, + }}} + defer func() { store.Relations = oldRelations }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/blocks", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMBlocks(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 || items[0].(map[string]any)["user_id"] != peer.UserId() { + t.Fatalf("unexpected blocks response: %#v", data) + } +} + +func TestServeIMBlocksBatchDelete(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + store.Relations = &testRelationsStore{} + defer func() { store.Relations = oldRelations }() + peer := types.Uid(2) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/blocks/batch-delete", bytes.NewBufferString(`{"user_ids":["`+peer.UserId()+`"]}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMBlockUser(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 || items[0].(map[string]any)["ok"] != true { + t.Fatalf("unexpected batch delete response: %#v", data) + } +} + func ptrTime(t time.Time) *time.Time { return &t } diff --git a/server/main.go b/server/main.go index ee7fb03..91d5dae 100644 --- a/server/main.go +++ b/server/main.go @@ -782,6 +782,7 @@ 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/urls/check", serveIMSecurityURLCheck) 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) @@ -792,11 +793,19 @@ func main() { 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/favorites", serveIMFavorites) + mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) + mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups) mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource) + mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes) + mux.HandleFunc(config.ApiPath+"v1/im/qrcodes/resolve", serveIMQRCodesResolve) + mux.HandleFunc(config.ApiPath+"v1/im/users/search", serveIMUserSearch) + mux.HandleFunc(config.ApiPath+"v1/im/users/", serveIMUserResource) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction) + mux.HandleFunc(config.ApiPath+"v1/im/blocks", serveIMBlocks) mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser) mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends) mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource) diff --git a/server/store/mock_store/mock_store.go b/server/store/mock_store/mock_store.go index cdf3673..cc4458f 100644 --- a/server/store/mock_store/mock_store.go +++ b/server/store/mock_store/mock_store.go @@ -1144,6 +1144,22 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, top return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt) } +// Search mocks base method. +func (m *MockMessagesPersistenceInterface) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", tenantID, topic, forUser, query, messageType, cursor, limit) + ret0, _ := ret[0].([]types.Message) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Search indicates an expected call of Search. +func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic, forUser, query, messageType, cursor, limit interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Search), tenantID, topic, forUser, query, messageType, cursor, limit) +} + // GetOne mocks base method. func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { m.ctrl.T.Helper() @@ -1342,6 +1358,21 @@ func (mr *MockFilePersistenceInterfaceMockRecorder) LinkAttachments(tenantID, to return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkAttachments", reflect.TypeOf((*MockFilePersistenceInterface)(nil).LinkAttachments), tenantID, topic, msgId, attachments) } +// UserHasAccess mocks base method. +func (m *MockFilePersistenceInterface) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UserHasAccess", tenantID, fid, uid) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UserHasAccess indicates an expected call of UserHasAccess. +func (mr *MockFilePersistenceInterfaceMockRecorder) UserHasAccess(tenantID, fid, uid interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserHasAccess", reflect.TypeOf((*MockFilePersistenceInterface)(nil).UserHasAccess), tenantID, fid, uid) +} + // StartUpload mocks base method. func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error { m.ctrl.T.Helper() diff --git a/server/store/store.go b/server/store/store.go index 3b1850e..e454f4a 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -149,6 +149,13 @@ func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, erro if !tenantID.IsValid() { return nil, types.ErrMalformed } + if routed, ok := adp.(adapter.TenantRoutedAdapter); ok { + _, authAdp, err := routed.TenantAdapterFor(tenantID) + if err != nil { + return nil, err + } + return authAdp, nil + } tenantAdp, ok := adp.(adapter.TenantAuthAdapter) if !ok { return nil, types.ErrUnsupported @@ -160,6 +167,17 @@ func tenantBusinessAdapter(tenantID types.TenantID) (adapter.TenantBusinessAdapt if !tenantID.IsValid() { return nil, types.ErrMalformed } + if routed, ok := adp.(adapter.TenantRoutedAdapter); ok { + businessAdp, _, err := routed.TenantAdapterFor(tenantID) + if err != nil { + return nil, err + } + if !businessAdp.TenantBusinessReady() { + return nil, types.ErrUnsupported + } + activeTenantIDs.Store(tenantID, struct{}{}) + return businessAdp, nil + } tenantAdp, ok := adp.(adapter.TenantBusinessAdapter) if !ok { return nil, types.ErrUnsupported @@ -960,6 +978,7 @@ type MessagesPersistenceInterface interface { DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error 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) + Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) } // messagesMapper is a concrete type implementing MessagesPersistenceInterface. @@ -1003,14 +1022,75 @@ type RelationsPersistenceInterface interface { GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) CanSendP2P(tenantID types.TenantID, userID, peerID types.Uid) (bool, string, error) BlockUser(tenantID types.TenantID, blockerID, blockedID types.Uid, now time.Time) (*types.UserBlock, error) + ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error) UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error + UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) + SearchUsers(tenantID types.TenantID, viewer types.Uid, query, searchType, cursor string, limit int) ([]types.UserSearchItem, string, error) } type relationsMapper struct{} var Relations RelationsPersistenceInterface +type FavoritesPersistenceInterface interface { + Create(tenantID types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) + List(tenantID types.TenantID, uid types.Uid, itemType, query, cursor string, limit int) ([]types.FavoriteItem, string, error) + Get(tenantID types.TenantID, uid types.Uid, favoriteID string) (*types.FavoriteItem, error) + Delete(tenantID types.TenantID, uid types.Uid, favoriteID string) error +} + +type favoritesMapper struct{} + +var Favorites FavoritesPersistenceInterface + +func (favoritesMapper) Create(tenantID types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) { + if item == nil || item.TenantID != tenantID || item.UserID.IsZero() || item.FavoriteID == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantFavoriteCreate(tenantID, item) +} + +func (favoritesMapper) List(tenantID types.TenantID, uid types.Uid, itemType, query, cursor string, limit int) ([]types.FavoriteItem, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantFavoriteList(tenantID, uid, itemType, query, cursor, limit) +} + +func (favoritesMapper) Get(tenantID types.TenantID, uid types.Uid, favoriteID string) (*types.FavoriteItem, error) { + if uid.IsZero() || strings.TrimSpace(favoriteID) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantFavoriteGet(tenantID, uid, favoriteID) +} + +func (favoritesMapper) Delete(tenantID types.TenantID, uid types.Uid, favoriteID string) error { + if uid.IsZero() || strings.TrimSpace(favoriteID) == "" { + return types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + return tenantAdp.TenantFavoriteDelete(tenantID, uid, favoriteID) +} + func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) { if req == nil || req.TenantID != tenantID || req.ApplicantID.IsZero() || req.TargetID.IsZero() || req.ApplicantID == req.TargetID { return nil, types.ErrMalformed @@ -1111,6 +1191,20 @@ func (relationsMapper) BlockUser(tenantID types.TenantID, blockerID, blockedID t return tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now) } +func (relationsMapper) ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error) { + if blockerID.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantUserBlockList(tenantID, blockerID, cursor, limit) +} + func (relationsMapper) UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error { if blockerID.IsZero() || blockedID.IsZero() || blockerID == blockedID { return types.ErrMalformed @@ -1133,6 +1227,68 @@ func (relationsMapper) DeleteFriend(tenantID types.TenantID, userID, peerID type return tenantAdp.TenantFriendshipDeletePair(tenantID, userID, peerID, now) } +func (relationsMapper) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) { + if userID.IsZero() || peerID.IsZero() || userID == peerID || len([]rune(remark)) > 64 { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantFriendshipUpdateRemark(tenantID, userID, peerID, remark, now) +} + +func (relationsMapper) SearchUsers(tenantID types.TenantID, viewer types.Uid, query, searchType, cursor string, limit int) ([]types.UserSearchItem, string, error) { + if viewer.IsZero() || strings.TrimSpace(query) == "" { + return nil, "", types.ErrMalformed + } + switch searchType { + case "", "account", "exact_account", "phone": + default: + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 50 { + limit = 20 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + users, nextCursor, err := tenantAdp.TenantUserSearch(tenantID, viewer, query, searchType, cursor, limit) + if err != nil { + return nil, "", err + } + items := make([]types.UserSearchItem, 0, len(users)) + for i := range users { + uid := users[i].Uid() + ctx, err := relationshipContext(tenantAdp, tenantID, viewer, uid, viewer.P2PName(uid)) + if err != nil { + return nil, "", err + } + items = append(items, types.UserSearchItem{ + User: &users[i], + Relationship: ctx.Relationship, + RequestID: ctx.RequestID, + CanAdd: ctx.Relationship == types.RelationshipNone, + Action: relationshipAction(ctx.Relationship), + }) + } + return items, nextCursor, nil +} + +func relationshipAction(relationship string) string { + switch relationship { + case types.RelationshipFriend: + return "chat" + case types.RelationshipPendingInbound, types.RelationshipPendingOutbound: + return "pending" + case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer: + return "none" + default: + return "add" + } +} + func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) { ctx := &types.RelationshipContext{ TenantID: tenantID, @@ -1381,6 +1537,20 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt) } +func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) { + if topic == "" || forUser.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit) +} + // GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list. func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) { tenantAdp, err := tenantBusinessAdapter(tenantID) @@ -1649,6 +1819,8 @@ type FilePersistenceInterface interface { // LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it // from being garbage collected. LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error + // UserHasAccess checks if user can download a file through an active upload, user, topic, or message reference. + UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) } // fileMapper is concrete type which implements FilePersistenceInterface. @@ -1738,6 +1910,17 @@ func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId t return nil } +func (fileMapper) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) { + if fid == "" || uid.IsZero() { + return false, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return false, err + } + return tenantAdp.TenantFileUserHasAccess(tenantID, fid, uid) +} + // PersistentCacheInterface is an interface which defines methods used for accessing persistent key-value cache. type PersistentCacheInterface interface { // Get reads a persistent cache entry. @@ -2004,4 +2187,5 @@ func init() { Verifications = verificationMapper{} Security = userSecurityMapper{} Relations = relationsMapper{} + Favorites = favoritesMapper{} } diff --git a/server/store/types/favorites.go b/server/store/types/favorites.go new file mode 100644 index 0000000..cca4a7d --- /dev/null +++ b/server/store/types/favorites.go @@ -0,0 +1,20 @@ +package types + +import "time" + +type FavoriteItem struct { + ID int64 + TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` + FavoriteID string `db:"favorite_id"` + UserID Uid `db:"user_id"` + ItemType string `db:"item_type"` + SourceTopic string `db:"source_topic"` + SourceSeq int `db:"source_seq"` + Snapshot any + AttachmentRefs any `db:"attachment_refs"` + Availability string `db:"availability"` + IdempotencyKey string `db:"idempotency_key"` + CollectedAt time.Time `db:"collected_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/server/store/types/relations.go b/server/store/types/relations.go index 49e6f53..ac7f4a2 100644 --- a/server/store/types/relations.go +++ b/server/store/types/relations.go @@ -74,3 +74,16 @@ type FriendRequestListItem struct { Peer *User Direction string } + +type UserSearchItem struct { + User *User + Relationship string + RequestID string + CanAdd bool + Action string +} + +type BlockListItem struct { + Block UserBlock + User *User +} diff --git a/server/store/types/tenant.go b/server/store/types/tenant.go index c479ba8..0e4753b 100644 --- a/server/store/types/tenant.go +++ b/server/store/types/tenant.go @@ -35,6 +35,8 @@ type Tenant struct { Code string `db:"code" json:"code"` Name string `db:"name" json:"name"` TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"` + DBName string `db:"db_name" json:"-"` + DBDSN string `db:"db_dsn" json:"-"` State TenantState `db:"state" json:"-"` CreatedAt time.Time `db:"created_at" json:"-"` CreatedBy int64 `db:"created_by" json:"-"` diff --git a/server/tinode.conf b/server/tinode.conf index a940e0f..e8b824c 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -290,7 +290,11 @@ "mysql": { // MySQL connection settings via DSN. // See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax. - "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci", + "dsn": "mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci", + // Public tenant registry database. Tenant business data remains in tinode or per-tenant DBs. + "tenant_registry_db": "hotgo", + // Route tenant-scoped auth/business queries to each tenant's DB according to hotgo.im_tenant. + "tenant_db_routing": true, // MySQL connection pool settings. // Maximum number of open connections to the database. Default: 0 (unlimited). "max_open_conns": 64, diff --git a/tinode-db/tinode.conf b/tinode-db/tinode.conf index 87cd8dd..297ce0d 100644 --- a/tinode-db/tinode.conf +++ b/tinode-db/tinode.conf @@ -10,7 +10,9 @@ }, "mysql": { "database": "tinode", - "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_unicode_ci" + "dsn": "mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_unicode_ci", + "tenant_registry_db": "hotgo", + "tenant_db_routing": true }, "rethinkdb": { "database": "tinode",