diff --git a/docs/third/IM-文件安全第三方接入记录.md b/docs/third/IM-文件安全第三方接入记录.md new file mode 100644 index 0000000..a001fc2 --- /dev/null +++ b/docs/third/IM-文件安全第三方接入记录.md @@ -0,0 +1,161 @@ +# IM 文件安全第三方接入记录 + +更新时间:2026-08-23 + +## 1. 接入范围 + +本次记录三条文件与安全链路: + +1. 阿里云 OSS:用于文件对象存储和签名下载,media handler 标识为 `oss`。 +2. 阿里云内容安全增强版:用于文件内容审核,当前 provider 标识为 `aliyun_green`,任务类型为 `content`。 +3. 阿里云云安全中心恶意文件检测:用于病毒、木马、WebShell、勒索等恶意文件检测,当前 provider 标识为 `aliyun_sas`,任务类型为 `malware`。 + +密钥、账号、Bucket 等敏感信息不写入本文档,也不写入源码。运行环境应通过配置中心或部署环境注入。 + +## 2. 阿里云 OSS 接入 + +新增媒体 handler:`server/media/oss`。 + +当前支持: + +- `Upload`:上传文件到 OSS Bucket,并把 OSS object key 写入 `FileDef.Location`。 +- `Headers`:对 GET/HEAD 生成 OSS 签名 URL,并返回 `308 Permanent Redirect`。 +- `Delete`:按 object key 批量删除 OSS 对象。 +- `GetIdFromUrl`:从 Tinode 文件 URL 中解析文件 ID。 + +对象路径规则: + +```text +tenants/{tenantID}/{yyyy}/{mm}/{fileID} +``` + +配置字段: + +| 字段 | 说明 | +| --- | --- | +| `access_key_id` | 阿里云 AccessKey ID,部署环境注入 | +| `access_key_secret` | 阿里云 AccessKey Secret,部署环境注入 | +| `endpoint` | OSS 普通 Bucket endpoint | +| `bucket` | OSS Bucket 名称 | +| `region_id` | OSS Bucket 所属地域 | +| `serve_url` | Tinode 对外文件 URL 前缀,默认 `/v0/file/s/` | +| `presign_ttl` | 签名 URL 有效秒数,默认 `120` | +| `cache_control` | 下载缓存头 | +| `cors_origins` | CORS 允许来源 | +| `verify_bucket` | 启动时是否联网校验 Bucket 存在 | +| `use_sys_config` | 为 `true` 时从 `hotgo.hg_sys_config` 读取 OSS 配置 | + +当前 `server/tinode.conf` 仍默认使用 `fs`。切换 OSS 时,将 `media.use_handler` 改为 `oss`,并在 `media.handlers.oss` 中设置 `use_sys_config=true` 后,运行时会从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取 OSS 配置。 + +代码入口: + +- Handler 实现:`server/media/oss/oss.go` +- 后台配置读取:`store.ThirdPartyConfig.Get("im_third_party", ...)` +- Handler 注册:`server/main.go` +- 单元测试:`server/media/oss/oss_tenant_test.go` + +注意事项: + +- 不使用 AccessPoint endpoint,当前按普通 Bucket endpoint 接入。 +- 不在源码或文档中保存真实 AK/SK。 +- 下载链路采用签名 URL 重定向,不由 Tinode 代理大文件内容。 + +## 3. 数据库状态机 + +新增租户业务库表:`im_file_review_tasks`。 + +独立租户库方案下,该表不包含 `tenant_id`。每个租户库保存本租户自己的文件审核任务。 + +核心字段: + +| 字段 | 说明 | +| --- | --- | +| `task_id` | 服务端生成的审核任务 ID | +| `file_id` | 对应 `im_fileuploads.id` | +| `user_id` | 上传用户 | +| `task_type` | `content` 或 `malware` | +| `provider` | `aliyun_green` 或 `aliyun_sas` | +| `object_name` | OSS 对象名快照 | +| `mime_type` | 文件 MIME 快照 | +| `file_size` | 文件大小快照 | +| `provider_task_id` | 第三方返回的任务 ID | +| `status` | `pending`、`running`、`clean`、`risky`、`failed` | +| `risk_level` | 第三方风险等级 | +| `risk_labels` | 命中的风险标签 JSON | +| `result_json` | 第三方原始结果 JSON | +| `attempts` / `next_retry_at` | 重试控制 | +| `completed_at` | 审核完成时间 | + +唯一约束: + +- `task_id` 全局唯一。 +- `file_id + task_type + provider` 唯一,避免同一文件重复创建同类任务。 + +## 4. 当前代码入口 + +文件上传完成后,在数据库事务内自动创建审核任务: + +- 普通/独立库路径:`server/db/mysql/adapter.go` 的 `FileFinishUpload` +- 共享库兼容路径:`server/db/mysql/tenant_adapter.go` 的 `TenantFileFinishUpload` +- 建表逻辑:`server/db/mysql/file_review_schema.go` +- 任务插入:`server/db/mysql/file_review_tasks.go` +- 状态常量和结构:`server/store/types/safety.go` + +当前策略是异步审核:上传成功后先生成 `pending` 任务,不阻塞上传响应。 + +## 5. 阿里云内容安全接入 + +新增封装包:`server/filesafety/aliyun`。 + +当前支持: + +- `SubmitOSSFile`:提交同账号 OSS 文件审核任务。 +- `PollOSSFile`:轮询文件审核结果。 +- 默认服务:`document_detection`。 +- 支持参数:`ossBucketName`、`ossObjectName`、`ossRegionId`、`dataId`。 + +文件安全配置通过 `server/filesafety.LoadConfig` 从后台库 `hotgo.hg_sys_config` 的 `im_third_party` 分组读取。内容安全启用后,`server/filesafety.NewGreenClientFromConfig` 可直接构建阿里云内容安全客户端;恶意文件检测当前先落配置和任务状态,具体云安全中心 API worker 后续补充。 + +结果映射: + +| 阿里云风险等级 | 内部状态 | +| --- | --- | +| 空 | `running` | +| `none` / `low` | `clean` | +| 其他风险等级 | `risky` | + +后续 worker 需要读取 `provider=aliyun_green,status=pending/running` 的任务,调用提交和轮询接口,并回写 `provider_task_id/status/risk_level/risk_labels/result_json`。 + +## 6. 阿里云恶意文件检测接入 + +当前已完成状态机和任务落库,上传成功会创建: + +- `task_type=malware` +- `provider=aliyun_sas` +- `status=pending` + +后续还需要补云安全中心恶意文件检测 SDK/HTTP client,并实现 worker 提交、轮询和结果映射。 + +建议映射: + +| 三方结果 | 内部状态 | +| --- | --- | +| 未完成 / 排队中 | `running` | +| 无风险 | `clean` | +| 命中恶意类型 | `risky` | +| 调用失败且可重试 | `pending`,更新 `attempts/next_retry_at` | +| 调用失败且不可重试 | `failed` | + +## 7. 本地库变更 + +已在本地 `tinode` 业务库创建并更新 `im_file_review_tasks` 表。 + +后续新库或已有库启动时,会通过 MySQL adapter 的 ensure 流程自动补表。 + +## 8. 后续待办 + +1. 实现文件安全 worker:批量领取 `pending` 任务、提交第三方、轮询结果、回写状态。 +2. 接入阿里云云安全中心恶意文件检测 client。 +3. 增加审核策略:是否阻断下载、是否只告警、是否对高风险文件自动下架。 +4. 在文件下载授权处读取审核结果,按策略限制 `risky/failed/running` 文件访问。 +5. 增加后台查询与人工处置接口,用于查看任务、重试、放行、下架。 diff --git a/docs/third/IM-第三方配置与文件安全SQL.sql b/docs/third/IM-第三方配置与文件安全SQL.sql new file mode 100644 index 0000000..102de8a --- /dev/null +++ b/docs/third/IM-第三方配置与文件安全SQL.sql @@ -0,0 +1,211 @@ +-- IM 第三方配置字典与文件安全表 +-- 更新时间:2026-08-23 +-- +-- 执行范围: +-- 1. hotgo 控制库:后台字典项与第三方配置项。 +-- 2. tinode 租户业务库:文件安全审核任务表。 +-- +-- 注意: +-- - 本脚本不写入真实 AccessKey、Secret、密码等敏感值。 +-- - 独立租户库方案下,业务表不包含 tenant_id。 +-- - 多租户时,im_file_review_tasks 需要在每个租户业务库执行。 + +USE hotgo; + +-- 1. 后台字典类型 +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM 存储服务商', 'im_storage_provider', 210, 'IM 文件存储 provider 字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_storage_provider'); + +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM 文件审核服务商', 'im_file_review_provider', 220, 'IM 文件审核 provider 字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_provider'); + +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM 文件审核任务类型', 'im_file_review_task_type', 230, 'IM 文件审核任务类型字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_task_type'); + +INSERT INTO hg_sys_dict_type + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM 文件审核状态', 'im_file_review_status', 240, 'IM 文件审核任务状态字典', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_type WHERE `type` = 'im_file_review_status'); + +-- 2. 后台字典数据 +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '阿里云 OSS', 'oss', 'string', 'im_storage_provider', 'success', 1, 10, '阿里云对象存储 OSS', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_storage_provider' AND `value` = 'oss'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '阿里云内容安全增强版', 'aliyun_green', 'string', 'im_file_review_provider', 'info', 1, 10, 'FileModeration + document_detection', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_provider' AND `value` = 'aliyun_green'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '阿里云云安全中心', 'aliyun_sas', 'string', 'im_file_review_provider', 'warning', 1, 20, '恶意文件检测', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_provider' AND `value` = 'aliyun_sas'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '内容审核', 'content', 'string', 'im_file_review_task_type', 'info', 1, 10, '文件内容审核任务', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_task_type' AND `value` = 'content'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '恶意文件检测', 'malware', 'string', 'im_file_review_task_type', 'warning', 1, 20, '病毒、木马、WebShell 等恶意文件检测任务', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_task_type' AND `value` = 'malware'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '待处理', 'pending', 'string', 'im_file_review_status', 'default', 1, 10, '等待 worker 提交或重试', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'pending'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '处理中', 'running', 'string', 'im_file_review_status', 'info', 1, 20, '第三方审核任务处理中', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'running'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '无风险', 'clean', 'string', 'im_file_review_status', 'success', 1, 30, '审核通过或无风险', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'clean'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '有风险', 'risky', 'string', 'im_file_review_status', 'error', 1, 40, '命中风险标签或恶意文件', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'risky'); + +INSERT INTO hg_sys_dict_data + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT '失败', 'failed', 'string', 'im_file_review_status', 'warning', 1, 50, '不可重试失败或超过重试次数', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_dict_data WHERE `type` = 'im_file_review_status' AND `value` = 'failed'); + +-- 3. 第三方配置项。配置真值存 hg_sys_config;字典只提供 UI 选项。 +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS 启用状态', 'bool', 'oss_enabled', '0', '0', 10, '是否启用阿里云 OSS 作为 IM 文件存储', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_enabled'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS 服务商', 'string', 'oss_provider', 'oss', 'oss', 11, '取值来自字典 im_storage_provider', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_provider'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS Endpoint', 'string', 'oss_endpoint', '', '', 12, '例如 oss-cn-shenzhen.aliyuncs.com,禁止写 AccessPoint endpoint', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_endpoint'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS Bucket', 'string', 'oss_bucket', '', '', 13, 'IM 文件对象存储 Bucket', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_bucket'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS Region', 'string', 'oss_region_id', 'cn-shenzhen', 'cn-shenzhen', 14, 'OSS Bucket 所属地域', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_region_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS AccessKey ID', 'string', 'oss_access_key_id', '', '', 15, '敏感配置,建议由部署环境或密钥系统注入', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_access_key_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS AccessKey Secret', 'string', 'oss_access_key_secret', '', '', 16, '敏感配置,后台和日志必须脱敏展示', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_access_key_secret'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', 'OSS 签名 URL TTL', 'int', 'oss_presign_ttl', '120', '120', 17, '签名下载 URL 有效秒数', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'oss_presign_ttl'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '文件内容安全启用状态', 'bool', 'file_review_content_enabled', '0', '0', 30, '是否启用阿里云内容安全增强版文件审核', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_content_enabled'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '内容安全 Provider', 'string', 'file_review_content_provider', 'aliyun_green', 'aliyun_green', 31, '取值来自字典 im_file_review_provider', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_content_provider'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '内容安全 Region', 'string', 'aliyun_green_region_id', 'cn-shanghai', 'cn-shanghai', 32, '阿里云内容安全增强版地域', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_green_region_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '内容安全 Endpoint', 'string', 'aliyun_green_endpoint', 'green-cip.cn-shanghai.aliyuncs.com', 'green-cip.cn-shanghai.aliyuncs.com', 33, '阿里云内容安全增强版 endpoint', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_green_endpoint'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '内容安全服务', 'string', 'aliyun_green_document_service', 'document_detection', 'document_detection', 34, 'FileModeration 服务名', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_green_document_service'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '恶意文件检测启用状态', 'bool', 'file_review_malware_enabled', '0', '0', 40, '是否启用阿里云云安全中心恶意文件检测', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_malware_enabled'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '恶意文件检测 Provider', 'string', 'file_review_malware_provider', 'aliyun_sas', 'aliyun_sas', 41, '取值来自字典 im_file_review_provider', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'file_review_malware_provider'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '恶意文件检测 Region', 'string', 'aliyun_sas_region_id', 'cn-shanghai', 'cn-shanghai', 42, '阿里云云安全中心恶意文件检测地域', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_sas_region_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '阿里云 AccessKey ID', 'string', 'aliyun_access_key_id', '', '', 90, '内容安全和恶意文件检测共用的敏感配置,建议由部署环境或密钥系统注入', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_access_key_id'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_third_party', '阿里云 AccessKey Secret', 'string', 'aliyun_access_key_secret', '', '', 91, '内容安全和恶意文件检测共用的敏感配置,后台和日志必须脱敏展示', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_third_party' AND `key` = 'aliyun_access_key_secret'); + +USE tinode; + +-- 4. 文件安全审核任务表 +CREATE TABLE IF NOT EXISTS im_file_review_tasks ( + id BIGINT NOT NULL, + task_id VARCHAR(64) NOT NULL, + file_id BIGINT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + task_type VARCHAR(32) NOT NULL, + provider VARCHAR(64) NOT NULL, + storage_provider VARCHAR(32) NOT NULL DEFAULT '', + object_name VARCHAR(2048) NOT NULL DEFAULT '', + mime_type VARCHAR(255) NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, + provider_task_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'pending', + risk_level VARCHAR(32) NOT NULL DEFAULT '', + risk_labels JSON, + result_json JSON, + error_code VARCHAR(64) NOT NULL DEFAULT '', + error_message VARCHAR(512) NOT NULL DEFAULT '', + attempts INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3), + completed_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_file_review_tasks_task(task_id), + UNIQUE INDEX uk_im_file_review_tasks_file_provider(file_id, task_type, provider), + INDEX idx_im_file_review_tasks_pending(status, next_retry_at, id), + INDEX idx_im_file_review_tasks_file(file_id), + INDEX idx_im_file_review_tasks_object(storage_provider, object_name(191)), + INDEX idx_im_file_review_tasks_provider_task(provider, provider_task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/go.mod b/go.mod index 2055fb5..09ce047 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.26.0 require ( firebase.google.com/go v3.13.0+incompatible + github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 + github.com/alibabacloud-go/green-20220302/v3 v3.5.4 + github.com/alibabacloud-go/tea v1.5.2 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/aws/aws-sdk-go-v2 v1.42.1 github.com/aws/aws-sdk-go-v2/config v1.32.27 @@ -43,6 +46,10 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect + github.com/aliyun/credentials-go v1.4.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect @@ -56,6 +63,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect @@ -64,11 +72,15 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.0 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect @@ -79,6 +91,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect golang.org/x/net v0.56.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 13f69c7..a8e05e7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -27,6 +28,7 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= @@ -35,8 +37,46 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk= +github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea v1.5.2 h1:DUzV/cRTbJC++rmADfqVu//GuWbaD64ZJ3yW3v2ukNU= +github.com/alibabacloud-go/tea v1.5.2/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0= +github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk= +github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= @@ -81,8 +121,13 @@ github.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2io github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -90,12 +135,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= @@ -113,19 +161,35 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -136,6 +200,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -153,6 +219,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -170,6 +240,13 @@ github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZyc github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ= github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -193,6 +270,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= @@ -206,6 +284,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -225,6 +306,9 @@ github.com/tinode/jsonco v1.0.0 h1:zVcpjzDvjuA1G+HLrckI5EiiRyq9jgV3x37OQl6e5FE= github.com/tinode/jsonco v1.0.0/go.mod h1:Bnavu3302Qfn2pILMNwASkelodgeew3IvDrbdzU84u8= github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE= github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/twilio/twilio-go v1.30.9 h1:4W4GEV2q0sLQ9xsr1N/97JQlt0c82hZ0ij4qTErstv8= github.com/twilio/twilio-go v1.30.9/go.mod h1:QbitvbvtkV77Jn4BABAKVmxabYSjMyQG4tHey9gfPqg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= @@ -235,6 +319,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= @@ -267,54 +353,138 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -323,16 +493,30 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU= google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM= google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -346,6 +530,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 h1:tczPZjdz6soV2thcuq1IFOuNLrBUGonFyUXBbIWXWis= gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2/go.mod h1:c7Wo0IjB7JL9B9Avv0UZKorYJCUhiergpj3u1WtGT1E= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -355,3 +542,5 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/server/db/adapter.go b/server/db/adapter.go index 957ad34..c2d5869 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -221,6 +221,12 @@ type RegistrationConfigAdapter interface { RegistrationSMSEnabled() (bool, error) } +// ThirdPartyConfigAdapter reads global third-party integration settings from +// the control database. +type ThirdPartyConfigAdapter interface { + ThirdPartyConfig(group string, keys ...string) (map[string]string, error) +} + // TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the // "one tenant, one database" deployment model. type TenantRoutedAdapter interface { diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 07d7e6f..3c6be6d 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -255,6 +255,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { if err = ensureIMHTTPInfrastructureTables(a.db); err != nil { return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error()) } + if err = ensureIMFileReviewTables(a.db); err != nil { + return errors.New("mysql adapter failed to ensure file review schema: " + err.Error()) + } if err = ensureSnowflakePrimaryKeys(a.db); err != nil { return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) } @@ -458,6 +461,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines db.Close() return nil, nil, err } + if err = ensureIMFileReviewTables(db); err != nil { + db.Close() + return nil, nil, err + } if err = ensureSnowflakePrimaryKeys(db); err != nil { db.Close() return nil, nil, err @@ -877,6 +884,9 @@ func (a *adapter) CreateDb(reset bool) error { if err = ensureIMHTTPInfrastructureTables(tx); err != nil { return err } + if err = ensureIMFileReviewTables(tx); err != nil { + return err + } // Deletion log if _, err = tx.Exec( @@ -4465,9 +4475,13 @@ func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t. if err != nil { return nil, err } + fd.UpdatedAt = now + fd.Size = size + if err = insertFileReviewTasks(ctx, tx, fd); err != nil { + return nil, err + } fd.Status = t.UploadCompleted - fd.Size = size } else { // Deleting the record: there is no value in keeping it in the DB. _, err = tx.ExecContext(ctx, "DELETE FROM im_fileuploads WHERE id=?", store.DecodeUid(fd.Uid())) diff --git a/server/db/mysql/file_review_schema.go b/server/db/mysql/file_review_schema.go new file mode 100644 index 0000000..6fe0344 --- /dev/null +++ b/server/db/mysql/file_review_schema.go @@ -0,0 +1,37 @@ +//go:build mysql +// +build mysql + +package mysql + +// ensureIMFileReviewTables installs async file safety review state in the +// currently selected tenant database. Tenant databases intentionally do not +// carry tenant_id columns. +func ensureIMFileReviewTables(exec sqlExecutor) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS im_file_review_tasks ( + id BIGINT NOT NULL, task_id VARCHAR(64) NOT NULL, file_id BIGINT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, task_type VARCHAR(32) NOT NULL, + provider VARCHAR(64) NOT NULL, storage_provider VARCHAR(32) NOT NULL DEFAULT '', + object_name VARCHAR(2048) NOT NULL DEFAULT '', mime_type VARCHAR(255) NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, provider_task_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'pending', risk_level VARCHAR(32) NOT NULL DEFAULT '', + risk_labels JSON, result_json JSON, error_code VARCHAR(64) NOT NULL DEFAULT '', + error_message VARCHAR(512) NOT NULL DEFAULT '', attempts INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3), completed_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_file_review_tasks_task(task_id), + UNIQUE INDEX uk_im_file_review_tasks_file_provider(file_id, task_type, provider), + INDEX idx_im_file_review_tasks_pending(status, next_retry_at, id), + INDEX idx_im_file_review_tasks_file(file_id), + INDEX idx_im_file_review_tasks_object(storage_provider, object_name(191)), + INDEX idx_im_file_review_tasks_provider_task(provider, provider_task_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + } + for _, statement := range statements { + if _, err := exec.Exec(statement); err != nil { + return err + } + } + return nil +} diff --git a/server/db/mysql/file_review_schema_test.go b/server/db/mysql/file_review_schema_test.go new file mode 100644 index 0000000..2384a93 --- /dev/null +++ b/server/db/mysql/file_review_schema_test.go @@ -0,0 +1,32 @@ +//go:build mysql + +package mysql + +import ( + "strings" + "testing" +) + +func TestEnsureIMFileReviewTablesUsesSelectedTenantDatabase(t *testing.T) { + exec := &recordingGroupSchemaExecutor{} + if err := ensureIMFileReviewTables(exec); err != nil { + t.Fatal(err) + } + if len(exec.statements) != 1 { + t.Fatalf("created %d file review tables, want 1", len(exec.statements)) + } + joined := strings.ToLower(strings.Join(exec.statements, "\n")) + for _, expected := range []string{ + "im_file_review_tasks", + "unique index uk_im_file_review_tasks_file_provider(file_id, task_type, provider)", + "index idx_im_file_review_tasks_pending(status, next_retry_at, id)", + "index idx_im_file_review_tasks_object(storage_provider, object_name(191))", + } { + if !strings.Contains(joined, expected) { + t.Fatalf("file review schema is missing %q", expected) + } + } + if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") { + t.Fatalf("file review schema must stay inside the selected tenant database: %s", joined) + } +} diff --git a/server/db/mysql/file_review_tasks.go b/server/db/mysql/file_review_tasks.go new file mode 100644 index 0000000..23bd461 --- /dev/null +++ b/server/db/mysql/file_review_tasks.go @@ -0,0 +1,47 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "context" + "database/sql" + + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" +) + +type fileReviewExecContext interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func insertFileReviewTasks(ctx context.Context, exec fileReviewExecContext, fd *t.FileDef) error { + fileID := decodeUID(fd.Uid()) + userID := int64(0) + if fd.User != "" { + userID = decodeUID(t.ParseUid(fd.User)) + } + tasks := []struct { + taskType string + provider string + }{ + {taskType: t.FileReviewTaskTypeContent, provider: t.FileReviewProviderAliyunGreen}, + {taskType: t.FileReviewTaskTypeMalware, provider: t.FileReviewProviderAliyunSAS}, + } + for _, task := range tasks { + id, err := nextSnowflakeID() + if err != nil { + return err + } + taskID := store.EncodeUid(id).String() + _, err = exec.ExecContext(ctx, `INSERT IGNORE INTO im_file_review_tasks + (id,task_id,file_id,user_id,task_type,provider,storage_provider,object_name,mime_type,file_size,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, taskID, fileID, userID, task.taskType, task.provider, "", fd.Location, fd.MimeType, fd.Size, + t.FileReviewStatusPending, fd.UpdatedAt, fd.UpdatedAt) + if err != nil { + return err + } + } + return nil +} diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql index b17557d..2f626f8 100644 --- a/server/db/mysql/schema.sql +++ b/server/db/mysql/schema.sql @@ -12,6 +12,7 @@ # - Default tenant business database: tinode # - im_users, im_topics, im_subscriptions, im_messages and extension tables # - im_http_idempotency and im_business_events_outbox for reliable HTTP writes +# - im_file_review_tasks for asynchronous content safety and malware checks # - business tables do not store tenant_id in independent-database mode # # The previous shared-table tenant_id draft has been removed from this file to diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 3b19795..77b6643 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -5439,8 +5439,12 @@ func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success b if success { _, err = tx.Exec("UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE tenant_id=? AND id=?", now, t.UploadCompleted, size, fd.ETag, fd.Location, id, decodeUID(fd.Uid())) + if err == nil { + fd.UpdatedAt = now + fd.Size = size + err = insertFileReviewTasks(ctx, tx, fd) + } fd.Status = t.UploadCompleted - fd.Size = size } else { _, err = tx.Exec("DELETE FROM im_fileuploads WHERE tenant_id=? AND id=?", id, decodeUID(fd.Uid())) fd.Status = t.UploadFailed diff --git a/server/db/mysql/third_party_config.go b/server/db/mysql/third_party_config.go new file mode 100644 index 0000000..58447a6 --- /dev/null +++ b/server/db/mysql/third_party_config.go @@ -0,0 +1,70 @@ +//go:build mysql +// +build mysql + +package mysql + +import ( + "database/sql" + "strings" + + "github.com/jmoiron/sqlx" + t "github.com/tinode/chat/server/store/types" +) + +func (a *adapter) ThirdPartyConfig(group string, keys ...string) (map[string]string, error) { + db := a.registryDB + if db == nil { + db = a.db + } + if db == nil { + return nil, t.ErrInternal + } + group = strings.TrimSpace(group) + if group == "" { + return nil, t.ErrMalformed + } + + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + + rows := []struct { + Key string `db:"key"` + Value string `db:"value"` + }{} + var err error + if len(keys) == 0 { + err = db.SelectContext(ctx, &rows, `SELECT `+"`key`"+`, value FROM hg_sys_config + WHERE `+"`group`"+`=? AND status=1`, group) + } else { + normalized := make([]string, 0, len(keys)) + for _, key := range keys { + if key = strings.TrimSpace(key); key != "" { + normalized = append(normalized, key) + } + } + if len(normalized) == 0 { + return map[string]string{}, nil + } + query, args, inErr := sqlx.In(`SELECT `+"`key`"+`, value FROM hg_sys_config + WHERE `+"`group`"+`=? AND status=1 AND `+"`key`"+` IN (?)`, group, normalized) + if inErr != nil { + return nil, inErr + } + query = db.Rebind(query) + err = db.SelectContext(ctx, &rows, query, args...) + } + if err != nil { + if err == sql.ErrNoRows { + return map[string]string{}, nil + } + return nil, err + } + + result := make(map[string]string, len(rows)) + for _, row := range rows { + result[row.Key] = row.Value + } + return result, nil +} diff --git a/server/filesafety/aliyun/green.go b/server/filesafety/aliyun/green.go new file mode 100644 index 0000000..ad0b481 --- /dev/null +++ b/server/filesafety/aliyun/green.go @@ -0,0 +1,243 @@ +// Package aliyun wraps Alibaba Cloud Green file moderation APIs. +package aliyun + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + green "github.com/alibabacloud-go/green-20220302/v3/client" + "github.com/alibabacloud-go/tea/dara" + + "github.com/tinode/chat/server/store/types" +) + +const defaultDocumentService = "document_detection" + +type GreenConfig struct { + AccessKeyID string + AccessKeySecret string + RegionID string + Endpoint string + DocumentService string +} + +type OSSObject struct { + BucketName string + ObjectName string + RegionID string + DataID string +} + +type SubmitResult struct { + ProviderTaskID string + RequestID string +} + +type PollResult struct { + Status string + RiskLevel string + RiskLabels []string + Raw map[string]any + RequestID string +} + +type GreenClient struct { + service string + client *green.Client +} + +func NewGreenClient(conf GreenConfig) (*GreenClient, error) { + if strings.TrimSpace(conf.AccessKeyID) == "" { + return nil, errors.New("missing Access Key ID") + } + if strings.TrimSpace(conf.AccessKeySecret) == "" { + return nil, errors.New("missing Access Key Secret") + } + if strings.TrimSpace(conf.RegionID) == "" { + return nil, errors.New("missing Region ID") + } + service := strings.TrimSpace(conf.DocumentService) + if service == "" { + service = defaultDocumentService + } + clientConf := &openapi.Config{ + AccessKeyId: dara.String(conf.AccessKeyID), + AccessKeySecret: dara.String(conf.AccessKeySecret), + RegionId: dara.String(conf.RegionID), + Protocol: dara.String("https"), + } + if endpoint := strings.TrimSpace(conf.Endpoint); endpoint != "" { + clientConf.Endpoint = dara.String(endpoint) + } + client, err := green.NewClient(clientConf) + if err != nil { + return nil, err + } + return &GreenClient{service: service, client: client}, nil +} + +func (c *GreenClient) SubmitOSSFile(ctx context.Context, object OSSObject) (*SubmitResult, error) { + params, err := buildOSSServiceParameters(object) + if err != nil { + return nil, err + } + resp, err := c.client.FileModerationWithContext(ctx, &green.FileModerationRequest{ + Service: dara.String(c.service), + ServiceParameters: dara.String(params), + }, &dara.RuntimeOptions{}) + if err != nil { + return nil, err + } + if resp == nil || resp.Body == nil || resp.Body.Code == nil || *resp.Body.Code != 200 || resp.Body.Data == nil || resp.Body.Data.TaskId == nil { + return nil, responseError("file moderation submit failed", responseCode(resp), responseMessage(resp)) + } + return &SubmitResult{ProviderTaskID: *resp.Body.Data.TaskId, RequestID: responseRequestID(resp)}, nil +} + +func (c *GreenClient) PollOSSFile(ctx context.Context, providerTaskID string) (*PollResult, error) { + params, err := json.Marshal(map[string]string{"taskId": strings.TrimSpace(providerTaskID)}) + if err != nil { + return nil, err + } + resp, err := c.client.DescribeFileModerationResultWithContext(ctx, &green.DescribeFileModerationResultRequest{ + Service: dara.String(c.service), + ServiceParameters: dara.String(string(params)), + }, &dara.RuntimeOptions{}) + if err != nil { + return nil, err + } + if resp == nil || resp.Body == nil || resp.Body.Code == nil || *resp.Body.Code != 200 { + return nil, responseError("file moderation poll failed", describeResponseCode(resp), describeResponseMessage(resp)) + } + raw := map[string]any{} + if data := resp.Body.Data; data != nil { + encoded, _ := json.Marshal(data) + json.Unmarshal(encoded, &raw) + riskLevel := strings.TrimSpace(dara.StringValue(data.RiskLevel)) + return &PollResult{ + Status: statusFromRiskLevel(riskLevel), + RiskLevel: riskLevel, + RiskLabels: labelsFromFileResult(data), + Raw: raw, + RequestID: describeResponseRequestID(resp), + }, nil + } + return &PollResult{Status: types.FileReviewStatusRunning, Raw: raw, RequestID: describeResponseRequestID(resp)}, nil +} + +func buildOSSServiceParameters(object OSSObject) (string, error) { + if strings.TrimSpace(object.BucketName) == "" || strings.TrimSpace(object.ObjectName) == "" || strings.TrimSpace(object.RegionID) == "" { + return "", types.ErrMalformed + } + params := map[string]string{ + "ossBucketName": strings.TrimSpace(object.BucketName), + "ossObjectName": strings.TrimSpace(object.ObjectName), + "ossRegionId": strings.TrimSpace(object.RegionID), + } + if dataID := strings.TrimSpace(object.DataID); dataID != "" { + params["dataId"] = dataID + } + encoded, err := json.Marshal(params) + return string(encoded), err +} + +func statusFromRiskLevel(riskLevel string) string { + switch strings.ToLower(strings.TrimSpace(riskLevel)) { + case "": + return types.FileReviewStatusRunning + case "none", "low": + return types.FileReviewStatusClean + default: + return types.FileReviewStatusRisky + } +} + +func labelsFromFileResult(data *green.DescribeFileModerationResultResponseBodyData) []string { + seen := map[string]bool{} + var labels []string + for _, page := range data.PageResult { + if page == nil { + continue + } + for _, image := range page.ImageResult { + if image == nil { + continue + } + for _, result := range image.LabelResult { + if result == nil { + continue + } + if label := strings.TrimSpace(dara.StringValue(result.Label)); label != "" && !seen[label] { + seen[label] = true + labels = append(labels, label) + } + } + } + for _, text := range page.TextResult { + if text == nil { + continue + } + for _, label := range strings.Split(dara.StringValue(text.Labels), ",") { + label = strings.TrimSpace(label) + if label != "" && !seen[label] { + seen[label] = true + labels = append(labels, label) + } + } + } + } + return labels +} + +func responseError(prefix string, code int32, msg string) error { + if msg == "" { + return errors.New(prefix) + } + return errors.New(prefix + ": " + msg + " (" + strconv.FormatInt(int64(code), 10) + ")") +} + +func responseCode(resp *green.FileModerationResponse) int32 { + if resp != nil && resp.Body != nil && resp.Body.Code != nil { + return *resp.Body.Code + } + return 0 +} + +func responseMessage(resp *green.FileModerationResponse) string { + if resp != nil && resp.Body != nil && resp.Body.Message != nil { + return *resp.Body.Message + } + return "" +} + +func responseRequestID(resp *green.FileModerationResponse) string { + if resp != nil && resp.Body != nil && resp.Body.RequestId != nil { + return *resp.Body.RequestId + } + return "" +} + +func describeResponseCode(resp *green.DescribeFileModerationResultResponse) int32 { + if resp != nil && resp.Body != nil && resp.Body.Code != nil { + return *resp.Body.Code + } + return 0 +} + +func describeResponseMessage(resp *green.DescribeFileModerationResultResponse) string { + if resp != nil && resp.Body != nil && resp.Body.Message != nil { + return *resp.Body.Message + } + return "" +} + +func describeResponseRequestID(resp *green.DescribeFileModerationResultResponse) string { + if resp != nil && resp.Body != nil && resp.Body.RequestId != nil { + return *resp.Body.RequestId + } + return "" +} diff --git a/server/filesafety/aliyun/green_test.go b/server/filesafety/aliyun/green_test.go new file mode 100644 index 0000000..ae92bbc --- /dev/null +++ b/server/filesafety/aliyun/green_test.go @@ -0,0 +1,70 @@ +package aliyun + +import ( + "encoding/json" + "testing" + + green "github.com/alibabacloud-go/green-20220302/v3/client" + "github.com/alibabacloud-go/tea/dara" + "github.com/tinode/chat/server/store/types" +) + +func TestBuildOSSServiceParameters(t *testing.T) { + got, err := buildOSSServiceParameters(OSSObject{ + BucketName: "im-oss-t1", + ObjectName: "tenants/1/2026/08/file", + RegionID: "cn-shenzhen", + DataID: "task-1", + }) + if err != nil { + t.Fatal(err) + } + var params map[string]string + if err := json.Unmarshal([]byte(got), ¶ms); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "ossBucketName": "im-oss-t1", + "ossObjectName": "tenants/1/2026/08/file", + "ossRegionId": "cn-shenzhen", + "dataId": "task-1", + } { + if params[key] != want { + t.Fatalf("params[%s] = %q, want %q", key, params[key], want) + } + } +} + +func TestStatusFromRiskLevel(t *testing.T) { + cases := map[string]string{ + "": types.FileReviewStatusRunning, + "none": types.FileReviewStatusClean, + "low": types.FileReviewStatusClean, + "medium": types.FileReviewStatusRisky, + "high": types.FileReviewStatusRisky, + } + for input, want := range cases { + if got := statusFromRiskLevel(input); got != want { + t.Fatalf("statusFromRiskLevel(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLabelsFromFileResult(t *testing.T) { + got := labelsFromFileResult(&green.DescribeFileModerationResultResponseBodyData{ + PageResult: []*green.DescribeFileModerationResultResponseBodyDataPageResult{ + {ImageResult: []*green.DescribeFileModerationResultResponseBodyDataPageResultImageResult{ + {LabelResult: []*green.DescribeFileModerationResultResponseBodyDataPageResultImageResultLabelResult{ + {Label: dara.String("porn")}, + {Label: dara.String("porn")}, + }}, + }}, + {TextResult: []*green.DescribeFileModerationResultResponseBodyDataPageResultTextResult{ + {Labels: dara.String("violence,ad")}, + }}, + }, + }) + if len(got) != 3 || got[0] != "porn" || got[1] != "violence" || got[2] != "ad" { + t.Fatalf("labelsFromFileResult = %#v", got) + } +} diff --git a/server/filesafety/config.go b/server/filesafety/config.go new file mode 100644 index 0000000..b336336 --- /dev/null +++ b/server/filesafety/config.go @@ -0,0 +1,104 @@ +package filesafety + +import ( + "errors" + "strconv" + "strings" + + "github.com/tinode/chat/server/filesafety/aliyun" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +const thirdPartyConfigGroup = "im_third_party" + +type ContentReviewConfig struct { + Enabled bool + Provider string + Green aliyun.GreenConfig +} + +type MalwareReviewConfig struct { + Enabled bool + Provider string + RegionID string +} + +type Config struct { + Content ContentReviewConfig + Malware MalwareReviewConfig +} + +func LoadConfig() (*Config, error) { + values, err := store.ThirdPartyConfig.Get(thirdPartyConfigGroup, + "file_review_content_enabled", + "file_review_content_provider", + "aliyun_green_region_id", + "aliyun_green_endpoint", + "aliyun_green_document_service", + "file_review_malware_enabled", + "file_review_malware_provider", + "aliyun_sas_region_id", + "aliyun_access_key_id", + "aliyun_access_key_secret", + ) + if err != nil { + return nil, err + } + + config := &Config{ + Content: ContentReviewConfig{ + Enabled: boolValue(values["file_review_content_enabled"]), + Provider: stringDefault(values["file_review_content_provider"], types.FileReviewProviderAliyunGreen), + Green: aliyun.GreenConfig{ + AccessKeyID: strings.TrimSpace(values["aliyun_access_key_id"]), + AccessKeySecret: strings.TrimSpace(values["aliyun_access_key_secret"]), + RegionID: strings.TrimSpace(values["aliyun_green_region_id"]), + Endpoint: strings.TrimSpace(values["aliyun_green_endpoint"]), + DocumentService: strings.TrimSpace(values["aliyun_green_document_service"]), + }, + }, + Malware: MalwareReviewConfig{ + Enabled: boolValue(values["file_review_malware_enabled"]), + Provider: stringDefault(values["file_review_malware_provider"], types.FileReviewProviderAliyunSAS), + RegionID: strings.TrimSpace(values["aliyun_sas_region_id"]), + }, + } + if config.Content.Enabled && config.Content.Provider != types.FileReviewProviderAliyunGreen { + return nil, errors.New("unsupported file content review provider: " + config.Content.Provider) + } + if config.Malware.Enabled && config.Malware.Provider != types.FileReviewProviderAliyunSAS { + return nil, errors.New("unsupported malware review provider: " + config.Malware.Provider) + } + return config, nil +} + +func NewGreenClientFromConfig() (*aliyun.GreenClient, error) { + config, err := LoadConfig() + if err != nil { + return nil, err + } + if !config.Content.Enabled { + return nil, types.ErrUnsupported + } + return aliyun.NewGreenClient(config.Content.Green) +} + +func boolValue(value string) bool { + if parsed, err := strconv.ParseBool(strings.TrimSpace(value)); err == nil { + return parsed + } + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "yes", "on": + return true + default: + return false + } +} + +func stringDefault(value, fallback string) string { + if value = strings.TrimSpace(value); value != "" { + return value + } + return fallback +} diff --git a/server/filesafety/config_test.go b/server/filesafety/config_test.go new file mode 100644 index 0000000..99b9a1f --- /dev/null +++ b/server/filesafety/config_test.go @@ -0,0 +1,51 @@ +package filesafety + +import ( + "testing" + + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +type fakeThirdPartyConfig map[string]string + +func (f fakeThirdPartyConfig) Get(group string, keys ...string) (map[string]string, error) { + result := map[string]string{} + for _, key := range keys { + if value, ok := f[key]; ok { + result[key] = value + } + } + return result, nil +} + +func TestLoadConfig(t *testing.T) { + original := store.ThirdPartyConfig + store.ThirdPartyConfig = fakeThirdPartyConfig{ + "file_review_content_enabled": "1", + "file_review_content_provider": types.FileReviewProviderAliyunGreen, + "aliyun_green_region_id": "cn-shanghai", + "aliyun_green_endpoint": "green-cip.cn-shanghai.aliyuncs.com", + "aliyun_green_document_service": "document_detection", + "file_review_malware_enabled": "true", + "file_review_malware_provider": types.FileReviewProviderAliyunSAS, + "aliyun_sas_region_id": "cn-shanghai", + "aliyun_access_key_id": "ak", + "aliyun_access_key_secret": "sk", + } + defer func() { store.ThirdPartyConfig = original }() + + config, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if !config.Content.Enabled || config.Content.Provider != types.FileReviewProviderAliyunGreen { + t.Fatalf("unexpected content config: %+v", config.Content) + } + if !config.Malware.Enabled || config.Malware.Provider != types.FileReviewProviderAliyunSAS { + t.Fatalf("unexpected malware config: %+v", config.Malware) + } + if config.Content.Green.AccessKeyID != "ak" || config.Content.Green.RegionID != "cn-shanghai" { + t.Fatalf("unexpected green config: %+v", config.Content.Green) + } +} diff --git a/server/media/oss/oss.go b/server/media/oss/oss.go index bb326b6..0e1b6e0 100644 --- a/server/media/oss/oss.go +++ b/server/media/oss/oss.go @@ -38,11 +38,13 @@ type ossConfig struct { AccessKeySecret string `json:"access_key_secret"` Endpoint string `json:"endpoint"` BucketName string `json:"bucket"` + RegionID string `json:"region_id"` CorsOrigins []string `json:"cors_origins"` ServeURL string `json:"serve_url"` PresignTTL int `json:"presign_ttl"` CacheControl string `json:"cache_control"` VerifyBucket bool `json:"verify_bucket"` + UseSysConfig bool `json:"use_sys_config"` } type ossHandler struct { @@ -69,6 +71,9 @@ func (oh *ossHandler) Init(jsconf string) error { if err = json.Unmarshal([]byte(jsconf), &oh.conf); err != nil { return errors.New("failed to parse config: " + err.Error()) } + if err = oh.loadSysConfig(); err != nil { + return err + } if oh.conf.AccessKeyID == "" { return errors.New("missing Access Key ID") @@ -114,6 +119,59 @@ func (oh *ossHandler) Init(jsconf string) error { return err } +func (oh *ossHandler) loadSysConfig() error { + if !oh.conf.UseSysConfig { + return nil + } + values, err := store.ThirdPartyConfig.Get("im_third_party", + "oss_enabled", + "oss_provider", + "oss_endpoint", + "oss_bucket", + "oss_region_id", + "oss_access_key_id", + "oss_access_key_secret", + "oss_presign_ttl", + ) + if err != nil { + return errors.New("failed to load OSS config from sys config: " + err.Error()) + } + if !truthy(values["oss_enabled"]) { + return errors.New("OSS is disabled in sys config") + } + if provider := strings.TrimSpace(values["oss_provider"]); provider != "" && provider != handlerName { + return errors.New("unsupported storage provider in sys config: " + provider) + } + applyString(&oh.conf.Endpoint, values["oss_endpoint"]) + applyString(&oh.conf.BucketName, values["oss_bucket"]) + applyString(&oh.conf.RegionID, values["oss_region_id"]) + applyString(&oh.conf.AccessKeyID, values["oss_access_key_id"]) + applyString(&oh.conf.AccessKeySecret, values["oss_access_key_secret"]) + if ttl := strings.TrimSpace(values["oss_presign_ttl"]); ttl != "" { + parsed, err := strconv.Atoi(ttl) + if err != nil { + return errors.New("invalid OSS presign TTL in sys config: " + err.Error()) + } + oh.conf.PresignTTL = parsed + } + return nil +} + +func applyString(target *string, value string) { + if value = strings.TrimSpace(value); value != "" { + *target = value + } +} + +func truthy(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + // Headers adds CORS headers and redirects GET and HEAD requests to OSS. func (oh *ossHandler) Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) { headers, status := media.CORSHandler(method, headers, oh.corsOrigins, serve) diff --git a/server/media/oss/oss_tenant_test.go b/server/media/oss/oss_tenant_test.go index 2134477..cb46e60 100644 --- a/server/media/oss/oss_tenant_test.go +++ b/server/media/oss/oss_tenant_test.go @@ -4,9 +4,22 @@ import ( "testing" "time" + "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) +type fakeThirdPartyConfig map[string]string + +func (f fakeThirdPartyConfig) Get(group string, keys ...string) (map[string]string, error) { + result := map[string]string{} + for _, key := range keys { + if value, ok := f[key]; ok { + result[key] = value + } + } + return result, nil +} + func TestTenantObjectKey(t *testing.T) { got, err := tenantObjectKey(types.TenantID(42), "abc123", time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC)) if err != nil { @@ -32,3 +45,29 @@ func TestGetIdFromUrl(t *testing.T) { t.Fatalf("GetIdFromUrl = %v, want %v", got, fid) } } + +func TestLoadSysConfig(t *testing.T) { + original := store.ThirdPartyConfig + store.ThirdPartyConfig = fakeThirdPartyConfig{ + "oss_enabled": "1", + "oss_provider": "oss", + "oss_endpoint": "oss-cn-shenzhen.aliyuncs.com", + "oss_bucket": "im-files", + "oss_region_id": "cn-shenzhen", + "oss_access_key_id": "ak", + "oss_access_key_secret": "sk", + "oss_presign_ttl": "300", + } + defer func() { store.ThirdPartyConfig = original }() + + handler := ossHandler{conf: ossConfig{UseSysConfig: true}} + if err := handler.loadSysConfig(); err != nil { + t.Fatalf("loadSysConfig failed: %v", err) + } + if handler.conf.Endpoint != "oss-cn-shenzhen.aliyuncs.com" || handler.conf.BucketName != "im-files" { + t.Fatalf("unexpected OSS config: %+v", handler.conf) + } + if handler.conf.RegionID != "cn-shenzhen" || handler.conf.PresignTTL != 300 { + t.Fatalf("unexpected OSS region or ttl: %+v", handler.conf) + } +} diff --git a/server/store/store.go b/server/store/store.go index f33a313..bcf3cb4 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -155,6 +155,24 @@ func (registrationConfigMapper) SMSEnabled() (bool, error) { return configAdp.RegistrationSMSEnabled() } +// ThirdPartyConfigPersistenceInterface exposes global third-party integration settings. +type ThirdPartyConfigPersistenceInterface interface { + Get(group string, keys ...string) (map[string]string, error) +} + +type thirdPartyConfigMapper struct{} + +// ThirdPartyConfig reads third-party settings from the control database. +var ThirdPartyConfig ThirdPartyConfigPersistenceInterface = thirdPartyConfigMapper{} + +func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]string, error) { + configAdp, ok := adp.(adapter.ThirdPartyConfigAdapter) + if !ok { + return nil, types.ErrUnsupported + } + return configAdp.ThirdPartyConfig(group, keys...) +} + // GetByCode resolves a tenant by its public enterprise code. func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) { tenantAdp, ok := adp.(adapter.TenantAdapter) diff --git a/server/store/types/safety.go b/server/store/types/safety.go index 1762fa1..477a253 100644 --- a/server/store/types/safety.go +++ b/server/store/types/safety.go @@ -10,6 +10,18 @@ const ( AbuseReportStatusReceived = "received" FeedbackStatusReceived = "received" + + FileReviewTaskTypeContent = "content" + FileReviewTaskTypeMalware = "malware" + + FileReviewProviderAliyunGreen = "aliyun_green" + FileReviewProviderAliyunSAS = "aliyun_sas" + + FileReviewStatusPending = "pending" + FileReviewStatusRunning = "running" + FileReviewStatusClean = "clean" + FileReviewStatusRisky = "risky" + FileReviewStatusFailed = "failed" ) type RiskChallenge struct { @@ -84,3 +96,28 @@ type FeedbackSubmission struct { CreatedAt time.Time UpdatedAt time.Time } + +type FileReviewTask struct { + ID int64 + TaskID string + FileID Uid + UserID Uid + TaskType string + Provider string + StorageProvider string + ObjectName string + MimeType string + FileSize int64 + ProviderTaskID string + Status string + RiskLevel string + RiskLabels []string + ResultJSON map[string]any + ErrorCode string + ErrorMessage string + Attempts int + NextRetryAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + CompletedAt *time.Time +}