diff --git a/docs/IM-企业入口注册反馈SQL.sql b/docs/IM-企业入口注册反馈SQL.sql
new file mode 100644
index 0000000..27488e8
--- /dev/null
+++ b/docs/IM-企业入口注册反馈SQL.sql
@@ -0,0 +1,66 @@
+-- IM 企业入口注册与问题反馈 SQL
+-- 更新时间:2026-08-24
+--
+-- 执行范围:hotgo 控制库。
+-- 说明:未登录企业注册、入口问题反馈发生在租户绑定前,因此公共附件和申请单不放入租户业务库。
+-- 文件对象通过 IM 服务端当前 media handler 存储;生产环境应配置 media.use_handler=oss。
+
+USE hotgo;
+
+CREATE TABLE IF NOT EXISTS im_public_uploads (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ file_id VARCHAR(64) NOT NULL,
+ purpose VARCHAR(64) NOT NULL,
+ mime_type VARCHAR(128) NOT NULL DEFAULT '',
+ size BIGINT NOT NULL DEFAULT 0,
+ etag VARCHAR(128) NOT NULL DEFAULT '',
+ location VARCHAR(2048) NOT NULL DEFAULT '',
+ expires_at DATETIME(3),
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_public_uploads_file(file_id),
+ INDEX idx_im_public_uploads_purpose(purpose, created_at),
+ INDEX idx_im_public_uploads_expires(expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+CREATE TABLE IF NOT EXISTS im_tenant_applications (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ application_id VARCHAR(64) NOT NULL,
+ credit_code VARCHAR(18) NOT NULL,
+ enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
+ license_file_id VARCHAR(64) NOT NULL DEFAULT '',
+ tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
+ client_context JSON,
+ idempotency_key VARCHAR(128) NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'received',
+ estimated_review_hour INT NOT NULL DEFAULT 24,
+ submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_applications_app(application_id),
+ UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
+ INDEX idx_im_tenant_applications_credit(credit_code),
+ INDEX idx_im_tenant_applications_status(status, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ feedback_id VARCHAR(64) NOT NULL,
+ description TEXT NOT NULL,
+ contact VARCHAR(255) NOT NULL DEFAULT '',
+ tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
+ screenshot_file_ids JSON,
+ client_context JSON,
+ idempotency_key VARCHAR(128) NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'received',
+ submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_entry_feedbacks_feedback(feedback_id),
+ UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
+ INDEX idx_im_tenant_entry_feedbacks_status(status, id),
+ INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md
index ba6ed67..89ade44 100644
--- a/docs/IM-前后端对接文档.md
+++ b/docs/IM-前后端对接文档.md
@@ -463,6 +463,109 @@ X-Tinode-APIKey: <预置的普通 API Key>
}
```
+### 1.1.1 企业注册-登录入口.png
+
+
+
+**状态:已实现。**
+
+- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。
+- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。
+- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。
+- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。
+- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。
+
+### 1.1.2 企业注册-填写注册信息.png
+
+
+
+**状态:已实现。**
+
+- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。
+- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。
+- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。
+- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。
+- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。
+- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
+- 请求体:
+
+```json
+{
+ "credit_code": "91310000MA1K000000",
+ "enterprise_email": "admin@example.com",
+ "license_file_id": "file_01K...",
+ "tenant_code_hint": "acme",
+ "client_context": {
+ "platform": "android",
+ "app_version": "1.0.0",
+ "device_model": "Pixel 8"
+ }
+}
+```
+
+- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。
+
+```json
+{
+ "application_id": "entapp_01K...",
+ "status": "received",
+ "estimated_review_hours": 24,
+ "submitted_at": "2026-08-24T08:30:15.123Z"
+}
+```
+
+- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。
+
+### 1.1.3 企业注册-提交成功提示.png
+
+
+
+**状态:已实现。**
+
+- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。
+- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。
+- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。
+- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。
+- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。
+
+### 1.1.4 企业注册-问题反馈.png
+
+
+
+**状态:已实现。**
+
+- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。
+- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。
+- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。
+- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。
+- 请求体:
+
+```json
+{
+ "description": "企业码 acme 提示不可用,无法进入企业。",
+ "contact": "user@example.com",
+ "tenant_code_hint": "acme",
+ "screenshot_file_ids": ["file_01K..."],
+ "client_context": {
+ "platform": "ios",
+ "app_version": "1.0.0",
+ "network": "wifi"
+ }
+}
+```
+
+- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。
+
+```json
+{
+ "feedback_id": "entryfb_01K...",
+ "status": "received",
+ "submitted_at": "2026-08-24T08:35:15.123Z"
+}
+```
+
+- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。
+
### 1.2 登录页面.png
@@ -642,6 +745,9 @@ X-Tinode-APIKey: <预置的普通 API Key>
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` |
+| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` |
+| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` |
+| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` |
| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` |
diff --git a/server/db/adapter.go b/server/db/adapter.go
index c2d5869..c23eee8 100644
--- a/server/db/adapter.go
+++ b/server/db/adapter.go
@@ -227,6 +227,13 @@ type ThirdPartyConfigAdapter interface {
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
}
+// TenantEntryAdapter stores public tenant entry data before a user is authenticated.
+type TenantEntryAdapter interface {
+ PublicUploadCreate(*t.PublicUpload) (*t.PublicUpload, error)
+ TenantApplicationCreate(*t.TenantApplication) (*t.TenantApplication, error)
+ TenantEntryFeedbackCreate(*t.TenantEntryFeedback) (*t.TenantEntryFeedback, 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 3c6be6d..d11233e 100644
--- a/server/db/mysql/adapter.go
+++ b/server/db/mysql/adapter.go
@@ -247,6 +247,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
}
+ if err = ensureIMTenantEntryControlTables(controlDB); err != nil {
+ return errors.New("mysql adapter failed to ensure tenant entry control schema: " + err.Error())
+ }
}
if !databaseMissing {
if err = ensureIMGroupBusinessTables(a.db); err != nil {
diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql
index 2f626f8..f233ab5 100644
--- a/server/db/mysql/schema.sql
+++ b/server/db/mysql/schema.sql
@@ -8,6 +8,8 @@
# - Control database: hotgo
# - im_tenant
# - im_tenant_config
+# - im_public_uploads, im_tenant_applications and im_tenant_entry_feedbacks
+# for unauthenticated enterprise registration and tenant-entry feedback
#
# - Default tenant business database: tinode
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
diff --git a/server/db/mysql/tenant_entry.go b/server/db/mysql/tenant_entry.go
new file mode 100644
index 0000000..6f44507
--- /dev/null
+++ b/server/db/mysql/tenant_entry.go
@@ -0,0 +1,201 @@
+//go:build mysql
+// +build mysql
+
+package mysql
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ t "github.com/tinode/chat/server/store/types"
+)
+
+func (a *adapter) PublicUploadCreate(upload *t.PublicUpload) (*t.PublicUpload, error) {
+ db := a.controlDB()
+ if db == nil {
+ return nil, t.ErrInternal
+ }
+ now := t.TimeNow()
+ if upload.CreatedAt.IsZero() {
+ upload.CreatedAt = now
+ }
+ if upload.UploadedAt.IsZero() {
+ upload.UploadedAt = now
+ }
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := db.ExecContext(ctx, `INSERT INTO im_public_uploads
+ (file_id,purpose,mime_type,size,etag,location,expires_at,created_at,uploaded_at)
+ VALUES(?,?,?,?,?,?,?,?,?)`,
+ upload.FileID, upload.Purpose, upload.MimeType, upload.Size, upload.ETag, upload.Location,
+ nullableTenantEntryTime(upload.ExpiresAt), upload.CreatedAt, upload.UploadedAt)
+ if err != nil {
+ return nil, err
+ }
+ return upload, nil
+}
+
+func (a *adapter) TenantApplicationCreate(application *t.TenantApplication) (*t.TenantApplication, error) {
+ db := a.controlDB()
+ if db == nil {
+ return nil, t.ErrInternal
+ }
+ now := t.TimeNow()
+ if application.Status == "" {
+ application.Status = t.TenantApplicationStatusReceived
+ }
+ if application.EstimatedReviewHour <= 0 {
+ application.EstimatedReviewHour = 24
+ }
+ if application.SubmittedAt.IsZero() {
+ application.SubmittedAt = now
+ }
+ application.CreatedAt = now
+ application.UpdatedAt = now
+ clientContext, _ := json.Marshal(application.ClientContext)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := db.ExecContext(ctx, `INSERT INTO im_tenant_applications
+ (application_id,credit_code,enterprise_email,license_file_id,tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
+ application.ApplicationID, application.CreditCode, application.EnterpriseEmail, application.LicenseFileID,
+ application.TenantCodeHint, string(clientContext), application.IdempotencyKey, application.Status,
+ application.EstimatedReviewHour, application.SubmittedAt, now, now)
+ if err != nil {
+ return a.tenantApplicationByIdempotency(application.IdempotencyKey)
+ }
+ return a.tenantApplicationByIdempotency(application.IdempotencyKey)
+}
+
+func (a *adapter) TenantEntryFeedbackCreate(feedback *t.TenantEntryFeedback) (*t.TenantEntryFeedback, error) {
+ db := a.controlDB()
+ if db == nil {
+ return nil, t.ErrInternal
+ }
+ now := t.TimeNow()
+ if feedback.Status == "" {
+ feedback.Status = t.TenantEntryFeedbackReceived
+ }
+ if feedback.SubmittedAt.IsZero() {
+ feedback.SubmittedAt = now
+ }
+ feedback.CreatedAt = now
+ feedback.UpdatedAt = now
+ screenshots, _ := json.Marshal(feedback.ScreenshotFileIDs)
+ clientContext, _ := json.Marshal(feedback.ClientContext)
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ _, err := db.ExecContext(ctx, `INSERT INTO im_tenant_entry_feedbacks
+ (feedback_id,description,contact,tenant_code_hint,screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ feedback.FeedbackID, feedback.Description, feedback.Contact, feedback.TenantCodeHint,
+ string(screenshots), string(clientContext), feedback.IdempotencyKey, feedback.Status,
+ feedback.SubmittedAt, now, now)
+ if err != nil {
+ return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
+ }
+ return a.tenantEntryFeedbackByIdempotency(feedback.IdempotencyKey)
+}
+
+func (a *adapter) tenantApplicationByIdempotency(idempotencyKey string) (*t.TenantApplication, error) {
+ db := a.controlDB()
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ row := struct {
+ ApplicationID string `db:"application_id"`
+ CreditCode string `db:"credit_code"`
+ EnterpriseEmail string `db:"enterprise_email"`
+ LicenseFileID string `db:"license_file_id"`
+ TenantCodeHint string `db:"tenant_code_hint"`
+ ClientContext []byte `db:"client_context"`
+ IdempotencyKey string `db:"idempotency_key"`
+ Status string `db:"status"`
+ EstimatedReviewHour int `db:"estimated_review_hour"`
+ SubmittedAt time.Time `db:"submitted_at"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+ }{}
+ if err := db.GetContext(ctx, &row, `SELECT application_id,credit_code,enterprise_email,license_file_id,
+ tenant_code_hint,client_context,idempotency_key,status,estimated_review_hour,submitted_at,created_at,updated_at
+ FROM im_tenant_applications WHERE idempotency_key=?`, idempotencyKey); err != nil {
+ return nil, err
+ }
+ app := &t.TenantApplication{
+ ApplicationID: row.ApplicationID,
+ CreditCode: row.CreditCode,
+ EnterpriseEmail: row.EnterpriseEmail,
+ LicenseFileID: row.LicenseFileID,
+ TenantCodeHint: row.TenantCodeHint,
+ IdempotencyKey: row.IdempotencyKey,
+ Status: row.Status,
+ EstimatedReviewHour: row.EstimatedReviewHour,
+ SubmittedAt: row.SubmittedAt,
+ CreatedAt: row.CreatedAt,
+ UpdatedAt: row.UpdatedAt,
+ }
+ _ = json.Unmarshal(row.ClientContext, &app.ClientContext)
+ return app, nil
+}
+
+func (a *adapter) tenantEntryFeedbackByIdempotency(idempotencyKey string) (*t.TenantEntryFeedback, error) {
+ db := a.controlDB()
+ ctx, cancel := a.getContext()
+ if cancel != nil {
+ defer cancel()
+ }
+ row := struct {
+ FeedbackID string `db:"feedback_id"`
+ Description string `db:"description"`
+ Contact string `db:"contact"`
+ TenantCodeHint string `db:"tenant_code_hint"`
+ ScreenshotFileIDs []byte `db:"screenshot_file_ids"`
+ ClientContext []byte `db:"client_context"`
+ IdempotencyKey string `db:"idempotency_key"`
+ Status string `db:"status"`
+ SubmittedAt time.Time `db:"submitted_at"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+ }{}
+ if err := db.GetContext(ctx, &row, `SELECT feedback_id,description,contact,tenant_code_hint,
+ screenshot_file_ids,client_context,idempotency_key,status,submitted_at,created_at,updated_at
+ FROM im_tenant_entry_feedbacks WHERE idempotency_key=?`, idempotencyKey); err != nil {
+ return nil, err
+ }
+ feedback := &t.TenantEntryFeedback{
+ FeedbackID: row.FeedbackID,
+ Description: row.Description,
+ Contact: row.Contact,
+ TenantCodeHint: row.TenantCodeHint,
+ IdempotencyKey: row.IdempotencyKey,
+ Status: row.Status,
+ SubmittedAt: row.SubmittedAt,
+ CreatedAt: row.CreatedAt,
+ UpdatedAt: row.UpdatedAt,
+ }
+ _ = json.Unmarshal(row.ScreenshotFileIDs, &feedback.ScreenshotFileIDs)
+ _ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
+ return feedback, nil
+}
+
+func (a *adapter) controlDB() *sqlx.DB {
+ if a.registryDB != nil {
+ return a.registryDB
+ }
+ return a.db
+}
+
+func nullableTenantEntryTime(value time.Time) any {
+ if value.IsZero() {
+ return nil
+ }
+ return value
+}
diff --git a/server/db/mysql/tenant_entry_schema.go b/server/db/mysql/tenant_entry_schema.go
new file mode 100644
index 0000000..fb46403
--- /dev/null
+++ b/server/db/mysql/tenant_entry_schema.go
@@ -0,0 +1,72 @@
+//go:build mysql
+// +build mysql
+
+package mysql
+
+import "github.com/jmoiron/sqlx"
+
+func ensureIMTenantEntryControlTables(db *sqlx.DB) error {
+ statements := []string{
+ `CREATE TABLE IF NOT EXISTS im_public_uploads(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ file_id VARCHAR(64) NOT NULL,
+ purpose VARCHAR(64) NOT NULL,
+ mime_type VARCHAR(128) NOT NULL DEFAULT '',
+ size BIGINT NOT NULL DEFAULT 0,
+ etag VARCHAR(128) NOT NULL DEFAULT '',
+ location VARCHAR(2048) NOT NULL DEFAULT '',
+ expires_at DATETIME(3),
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_public_uploads_file(file_id),
+ INDEX idx_im_public_uploads_purpose(purpose, created_at),
+ INDEX idx_im_public_uploads_expires(expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
+ `CREATE TABLE IF NOT EXISTS im_tenant_applications(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ application_id VARCHAR(64) NOT NULL,
+ credit_code VARCHAR(18) NOT NULL,
+ enterprise_email VARCHAR(255) NOT NULL DEFAULT '',
+ license_file_id VARCHAR(64) NOT NULL DEFAULT '',
+ tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
+ client_context JSON,
+ idempotency_key VARCHAR(128) NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'received',
+ estimated_review_hour INT NOT NULL DEFAULT 24,
+ submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_applications_app(application_id),
+ UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key),
+ INDEX idx_im_tenant_applications_credit(credit_code),
+ INDEX idx_im_tenant_applications_status(status, id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
+ `CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ feedback_id VARCHAR(64) NOT NULL,
+ description TEXT NOT NULL,
+ contact VARCHAR(255) NOT NULL DEFAULT '',
+ tenant_code_hint VARCHAR(64) NOT NULL DEFAULT '',
+ screenshot_file_ids JSON,
+ client_context JSON,
+ idempotency_key VARCHAR(128) NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'received',
+ submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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_tenant_entry_feedbacks_feedback(feedback_id),
+ UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key),
+ INDEX idx_im_tenant_entry_feedbacks_status(status, id),
+ INDEX idx_im_tenant_entry_feedbacks_hint(tenant_code_hint, id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
+ }
+ for _, stmt := range statements {
+ if _, err := db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/server/db/mysql/tenant_entry_schema_test.go b/server/db/mysql/tenant_entry_schema_test.go
new file mode 100644
index 0000000..e745569
--- /dev/null
+++ b/server/db/mysql/tenant_entry_schema_test.go
@@ -0,0 +1,40 @@
+//go:build mysql
+// +build mysql
+
+package mysql
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestEnsureTenantEntryControlTablesStatements(t *testing.T) {
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS im_public_uploads(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ file_id VARCHAR(64) NOT NULL,
+ purpose VARCHAR(64) NOT NULL,
+ PRIMARY KEY(id),
+ UNIQUE INDEX uk_im_public_uploads_file(file_id)
+ )`,
+ `CREATE TABLE IF NOT EXISTS im_tenant_applications(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ application_id VARCHAR(64) NOT NULL,
+ credit_code VARCHAR(18) NOT NULL,
+ idempotency_key VARCHAR(128) NOT NULL,
+ UNIQUE INDEX uk_im_tenant_applications_idempotency(idempotency_key)
+ )`,
+ `CREATE TABLE IF NOT EXISTS im_tenant_entry_feedbacks(
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ feedback_id VARCHAR(64) NOT NULL,
+ idempotency_key VARCHAR(128) NOT NULL,
+ UNIQUE INDEX uk_im_tenant_entry_feedbacks_idempotency(idempotency_key)
+ )`,
+ }
+ joined := strings.ToLower(strings.Join(stmts, "\n"))
+ for _, want := range []string{"im_public_uploads", "im_tenant_applications", "im_tenant_entry_feedbacks", "idempotency_key"} {
+ if !strings.Contains(joined, want) {
+ t.Fatalf("schema statement missing %s", want)
+ }
+ }
+}
diff --git a/server/im_http.go b/server/im_http.go
index 73e8372..86c2114 100644
--- a/server/im_http.go
+++ b/server/im_http.go
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"math/big"
+ "mime"
"net"
"net/http"
"net/url"
@@ -23,6 +24,7 @@ import (
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
+ "github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
@@ -48,6 +50,9 @@ const (
imReasonIdempotencyConflict = "IDEMPOTENCY_CONFLICT"
imReasonIdempotencyPending = "IDEMPOTENCY_IN_PROGRESS"
imReasonInternal = "INTERNAL_ERROR"
+ imReasonUnsupportedMedia = "UNSUPPORTED_MEDIA_TYPE"
+ imReasonFileUnavailable = "FILE_UNAVAILABLE"
+ imReasonRateLimited = "RATE_LIMITED"
)
const (
@@ -60,6 +65,7 @@ const (
imIndependentPasswordLockSeconds = 300
imQRCodeDefaultExpiresInSeconds = 86400
imQRCodeMaxExpiresInSeconds = 7 * 86400
+ imPublicUploadMaxBytes = 10 << 20
groupMaxAdminCount = 10
)
@@ -233,6 +239,22 @@ type imTenantResolveRequest struct {
AppVersion string `json:"app_version,omitempty"`
}
+type imTenantApplicationRequest struct {
+ CreditCode string `json:"credit_code"`
+ EnterpriseEmail string `json:"enterprise_email,omitempty"`
+ LicenseFileID string `json:"license_file_id,omitempty"`
+ TenantCodeHint string `json:"tenant_code_hint,omitempty"`
+ ClientContext map[string]any `json:"client_context,omitempty"`
+}
+
+type imTenantEntryFeedbackRequest struct {
+ Description string `json:"description"`
+ Contact string `json:"contact,omitempty"`
+ TenantCodeHint string `json:"tenant_code_hint,omitempty"`
+ ScreenshotFileIDs []string `json:"screenshot_file_ids,omitempty"`
+ ClientContext map[string]any `json:"client_context,omitempty"`
+}
+
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
Scene string `json:"scene"`
@@ -576,6 +598,182 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
})
}
+func serveIMPublicUploads(wrt http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
+ return
+ }
+ if store.Store == nil || store.Store.GetMediaHandler() == nil {
+ writeIMError(wrt, req, http.StatusServiceUnavailable, 90000, imReasonInternal, "media handler is not configured", nil)
+ return
+ }
+ uploader, ok := store.Store.GetMediaHandler().(media.PublicUploader)
+ if !ok {
+ writeIMError(wrt, req, http.StatusServiceUnavailable, 30000, imReasonUnsupportedMedia, "public upload is not supported", nil)
+ return
+ }
+ req.Body = http.MaxBytesReader(wrt, req.Body, imPublicUploadMaxBytes)
+ if err := req.ParseMultipartForm(imPublicUploadMaxBytes); err != nil {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed multipart form", nil)
+ return
+ }
+ purpose := strings.TrimSpace(req.FormValue("purpose"))
+ if !isAllowedPublicUploadPurpose(purpose) {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "purpose"})
+ return
+ }
+ file, header, err := req.FormFile("file")
+ if err != nil {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "file is required", map[string]any{"field": "file"})
+ return
+ }
+ defer file.Close()
+
+ mimeType, err := detectIMUploadMime(file, header.Header.Get("Content-Type"))
+ if err != nil {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed file", nil)
+ return
+ }
+ if !isAllowedPublicUploadMime(purpose, mimeType) {
+ writeIMError(wrt, req, http.StatusUnsupportedMediaType, 30000, imReasonUnsupportedMedia, "unsupported media type", map[string]any{"mime_type": mimeType})
+ return
+ }
+
+ token, err := randomIMToken(18)
+ if err != nil {
+ writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
+ return
+ }
+ now := time.Now().UTC().Round(time.Millisecond)
+ publicFile := &media.PublicFileDef{
+ ID: "file_" + token,
+ Purpose: purpose,
+ MimeType: mimeType,
+ CreatedAt: now,
+ }
+ location, size, err := uploader.UploadPublic(publicFile, file)
+ if err != nil {
+ writeIMRelationError(wrt, req, err)
+ return
+ }
+ if location != "" {
+ publicFile.Location = location
+ }
+ upload, err := store.TenantEntry.CreatePublicUpload(&types.PublicUpload{
+ FileID: publicFile.ID,
+ Purpose: purpose,
+ MimeType: mimeType,
+ Size: size,
+ ETag: publicFile.ETag,
+ Location: publicFile.Location,
+ ExpiresAt: now.Add(24 * time.Hour),
+ CreatedAt: now,
+ UploadedAt: now,
+ })
+ if err != nil {
+ _ = store.Store.GetMediaHandler().Delete([]string{publicFile.Location})
+ writeIMRelationError(wrt, req, err)
+ return
+ }
+ writeIMSuccess(wrt, req, publicUploadPayload(upload))
+}
+
+func serveIMTenantApplications(wrt http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
+ return
+ }
+ var body imTenantApplicationRequest
+ if !decodeIMJSONBody(wrt, req, &body) {
+ return
+ }
+ idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
+ creditCode := strings.ToUpper(strings.TrimSpace(body.CreditCode))
+ email := strings.TrimSpace(body.EnterpriseEmail)
+ if idempotencyKey == "" {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
+ return
+ }
+ if !isValidCreditCode(creditCode) {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_CREDIT_CODE", "invalid credit code", map[string]any{"field": "credit_code"})
+ return
+ }
+ if email != "" && !isValidIMEmail(email) {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, "INVALID_EMAIL", "invalid email", map[string]any{"field": "enterprise_email"})
+ return
+ }
+ token, err := randomIMToken(18)
+ if err != nil {
+ writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
+ return
+ }
+ app, err := store.TenantEntry.CreateTenantApplication(&types.TenantApplication{
+ ApplicationID: "entapp_" + token,
+ CreditCode: creditCode,
+ EnterpriseEmail: email,
+ LicenseFileID: strings.TrimSpace(body.LicenseFileID),
+ TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
+ ClientContext: body.ClientContext,
+ IdempotencyKey: idempotencyKey,
+ Status: types.TenantApplicationStatusReceived,
+ EstimatedReviewHour: 24,
+ })
+ if err != nil {
+ writeIMRelationError(wrt, req, err)
+ return
+ }
+ writeIMSuccess(wrt, req, tenantApplicationPayload(app))
+}
+
+func serveIMTenantEntryFeedback(wrt http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
+ return
+ }
+ var body imTenantEntryFeedbackRequest
+ if !decodeIMJSONBody(wrt, req, &body) {
+ return
+ }
+ idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
+ description := strings.TrimSpace(body.Description)
+ if idempotencyKey == "" {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "Idempotency-Key is required", map[string]any{"field": "Idempotency-Key"})
+ return
+ }
+ if len([]rune(description)) < 10 {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_REQUIRED", "description is required", map[string]any{"field": "description"})
+ return
+ }
+ if len([]rune(description)) > 1000 {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, "DESCRIPTION_TOO_LONG", "description is too long", map[string]any{"field": "description"})
+ return
+ }
+ if len(body.ScreenshotFileIDs) > 9 {
+ writeIMError(wrt, req, http.StatusBadRequest, 30000, "TOO_MANY_FILES", "too many files", map[string]any{"field": "screenshot_file_ids"})
+ return
+ }
+ token, err := randomIMToken(18)
+ if err != nil {
+ writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
+ return
+ }
+ feedback, err := store.TenantEntry.CreateTenantEntryFeedback(&types.TenantEntryFeedback{
+ FeedbackID: "entryfb_" + token,
+ Description: description,
+ Contact: strings.TrimSpace(body.Contact),
+ TenantCodeHint: cleanTenantCodeHint(body.TenantCodeHint),
+ ScreenshotFileIDs: cleanStringList(body.ScreenshotFileIDs, 9),
+ ClientContext: body.ClientContext,
+ IdempotencyKey: idempotencyKey,
+ Status: types.TenantEntryFeedbackReceived,
+ })
+ if err != nil {
+ writeIMRelationError(wrt, req, err)
+ return
+ }
+ writeIMSuccess(wrt, req, tenantEntryFeedbackPayload(feedback))
+}
+
func imPublicEndpoints(req *http.Request) map[string]any {
base := imPublicHTTPBase(req)
wsBase := base
@@ -7237,6 +7435,33 @@ func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]an
}
}
+func publicUploadPayload(upload *types.PublicUpload) map[string]any {
+ return map[string]any{
+ "file_id": upload.FileID,
+ "purpose": upload.Purpose,
+ "mime_type": upload.MimeType,
+ "size": upload.Size,
+ "expires_at": upload.ExpiresAt.UTC().Format(time.RFC3339Nano),
+ }
+}
+
+func tenantApplicationPayload(application *types.TenantApplication) map[string]any {
+ return map[string]any{
+ "application_id": application.ApplicationID,
+ "status": application.Status,
+ "estimated_review_hours": application.EstimatedReviewHour,
+ "submitted_at": application.SubmittedAt.UTC().Format(time.RFC3339Nano),
+ }
+}
+
+func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]any {
+ return map[string]any{
+ "feedback_id": feedback.FeedbackID,
+ "status": feedback.Status,
+ "submitted_at": feedback.SubmittedAt.UTC().Format(time.RFC3339Nano),
+ }
+}
+
func isAllowedIMWorkbenchRoute(route string) bool {
switch route {
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
@@ -8086,6 +8311,80 @@ func cleanStringList(values []string, limit int) []string {
return cleaned
}
+func isAllowedPublicUploadPurpose(purpose string) bool {
+ switch strings.TrimSpace(purpose) {
+ case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback:
+ return true
+ default:
+ return false
+ }
+}
+
+func isAllowedPublicUploadMime(purpose, mimeType string) bool {
+ mimeType = strings.ToLower(strings.TrimSpace(mimeType))
+ switch purpose {
+ case types.PublicUploadPurposeEnterpriseLicense:
+ return strings.HasPrefix(mimeType, "image/jpeg") ||
+ strings.HasPrefix(mimeType, "image/png") ||
+ strings.HasPrefix(mimeType, "image/webp") ||
+ mimeType == "application/pdf"
+ case types.PublicUploadPurposeTenantFeedback:
+ return strings.HasPrefix(mimeType, "image/jpeg") ||
+ strings.HasPrefix(mimeType, "image/png") ||
+ strings.HasPrefix(mimeType, "image/webp")
+ default:
+ return false
+ }
+}
+
+func detectIMUploadMime(file interface {
+ io.Reader
+ io.Seeker
+}, contentType string) (string, error) {
+ buff := make([]byte, 512)
+ n, err := file.Read(buff)
+ if err != nil && err != io.EOF {
+ return "", err
+ }
+ if _, err = file.Seek(0, io.SeekStart); err != nil {
+ return "", err
+ }
+ mimeType := http.DetectContentType(buff[:n])
+ if mimeType == "application/octet-stream" {
+ if parsed, _, err := mime.ParseMediaType(contentType); err == nil && parsed != "" {
+ mimeType = parsed
+ }
+ }
+ return strings.ToLower(strings.TrimSpace(mimeType)), nil
+}
+
+func isValidCreditCode(code string) bool {
+ if len(code) != 18 {
+ return false
+ }
+ for _, r := range code {
+ if !(r >= '0' && r <= '9' || r >= 'A' && r <= 'Z') {
+ return false
+ }
+ }
+ return true
+}
+
+func isValidIMEmail(email string) bool {
+ if len(email) > 255 || strings.ContainsAny(email, "\r\n\t ") {
+ return false
+ }
+ return regexp.MustCompile(`^[^@]+@[^@]+\.[^@]+$`).MatchString(email)
+}
+
+func cleanTenantCodeHint(code string) string {
+ code = strings.TrimSpace(code)
+ if len(code) > 64 {
+ return code[:64]
+ }
+ return code
+}
+
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) {
targetType := strings.ToLower(strings.TrimSpace(target.Type))
snapshot := map[string]any{"type": targetType}
diff --git a/server/im_http_test.go b/server/im_http_test.go
index 55320ee..7df3a69 100644
--- a/server/im_http_test.go
+++ b/server/im_http_test.go
@@ -8,8 +8,10 @@ import (
"encoding/binary"
"encoding/json"
"io"
+ "mime/multipart"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
"time"
@@ -17,6 +19,7 @@ import (
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
+ "github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
@@ -39,6 +42,65 @@ type testHTTPInfrastructureStore struct {
events []types.BusinessEvent
}
+type testTenantEntryStore struct {
+ upload *types.PublicUpload
+ application *types.TenantApplication
+ feedback *types.TenantEntryFeedback
+}
+
+func (s *testTenantEntryStore) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) {
+ now := time.Now().UTC().Round(time.Millisecond)
+ upload.CreatedAt = now
+ upload.UploadedAt = now
+ s.upload = upload
+ return upload, nil
+}
+
+func (s *testTenantEntryStore) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) {
+ now := time.Now().UTC().Round(time.Millisecond)
+ application.Status = types.TenantApplicationStatusReceived
+ application.EstimatedReviewHour = 24
+ application.SubmittedAt = now
+ s.application = application
+ return application, nil
+}
+
+func (s *testTenantEntryStore) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) {
+ now := time.Now().UTC().Round(time.Millisecond)
+ feedback.Status = types.TenantEntryFeedbackReceived
+ feedback.SubmittedAt = now
+ s.feedback = feedback
+ return feedback, nil
+}
+
+type testPublicMediaHandler struct {
+ def *media.PublicFileDef
+ data string
+}
+
+func (h *testPublicMediaHandler) Init(string) error { return nil }
+func (h *testPublicMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) {
+ return nil, 0, nil
+}
+func (h *testPublicMediaHandler) Upload(*types.FileDef, io.Reader) (string, int64, error) {
+ return "", 0, types.ErrUnsupported
+}
+func (h *testPublicMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) {
+ return nil, nil, types.ErrUnsupported
+}
+func (h *testPublicMediaHandler) Delete([]string) error { return nil }
+func (h *testPublicMediaHandler) GetIdFromUrl(string) types.Uid {
+ return types.ZeroUid
+}
+func (h *testPublicMediaHandler) UploadPublic(def *media.PublicFileDef, file io.Reader) (string, int64, error) {
+ data, _ := io.ReadAll(file)
+ h.def = def
+ h.data = string(data)
+ def.Location = "public/" + def.Purpose + "/" + def.ID
+ def.ETag = "etag"
+ return def.Location, int64(len(data)), nil
+}
+
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
@@ -1357,6 +1419,117 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
}
}
+func TestServeIMTenantApplications(t *testing.T) {
+ oldTenantEntry := store.TenantEntry
+ entry := &testTenantEntryStore{}
+ store.TenantEntry = entry
+ defer func() { store.TenantEntry = oldTenantEntry }()
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-applications", strings.NewReader(`{
+ "credit_code":"91310000ma1k000000",
+ "enterprise_email":"admin@example.com",
+ "license_file_id":"file_123",
+ "tenant_code_hint":"acme",
+ "client_context":{"platform":"android"}
+ }`))
+ req.Header.Set("Idempotency-Key", "tenant-app-1")
+ rec := httptest.NewRecorder()
+
+ serveIMTenantApplications(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
+ }
+ if entry.application == nil || entry.application.CreditCode != "91310000MA1K000000" ||
+ entry.application.EnterpriseEmail != "admin@example.com" || entry.application.LicenseFileID != "file_123" {
+ t.Fatalf("unexpected application: %#v", entry.application)
+ }
+ data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
+ if !strings.HasPrefix(data["application_id"].(string), "entapp_") || data["status"] != types.TenantApplicationStatusReceived {
+ t.Fatalf("unexpected application response: %#v", data)
+ }
+}
+
+func TestServeIMTenantEntryFeedback(t *testing.T) {
+ oldTenantEntry := store.TenantEntry
+ entry := &testTenantEntryStore{}
+ store.TenantEntry = entry
+ defer func() { store.TenantEntry = oldTenantEntry }()
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-entry-feedback", strings.NewReader(`{
+ "description":"企业码 acme 提示不可用,无法进入企业。",
+ "contact":"user@example.com",
+ "tenant_code_hint":"acme",
+ "screenshot_file_ids":["file_1","file_1","file_2"],
+ "client_context":{"platform":"ios"}
+ }`))
+ req.Header.Set("Idempotency-Key", "entry-feedback-1")
+ rec := httptest.NewRecorder()
+
+ serveIMTenantEntryFeedback(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
+ }
+ if entry.feedback == nil || entry.feedback.Description == "" || len(entry.feedback.ScreenshotFileIDs) != 2 {
+ t.Fatalf("unexpected feedback: %#v", entry.feedback)
+ }
+ data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
+ if !strings.HasPrefix(data["feedback_id"].(string), "entryfb_") || data["status"] != types.TenantEntryFeedbackReceived {
+ t.Fatalf("unexpected feedback response: %#v", data)
+ }
+}
+
+func TestServeIMPublicUploads(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ oldStore := store.Store
+ oldTenantEntry := store.TenantEntry
+ entry := &testTenantEntryStore{}
+ handler := &testPublicMediaHandler{}
+ mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
+ mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
+ store.Store = mockStore
+ store.TenantEntry = entry
+ defer func() {
+ store.Store = oldStore
+ store.TenantEntry = oldTenantEntry
+ }()
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ if err := writer.WriteField("purpose", types.PublicUploadPurposeTenantFeedback); err != nil {
+ t.Fatal(err)
+ }
+ part, err := writer.CreateFormFile("file", "shot.png")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ rec := httptest.NewRecorder()
+
+ serveIMPublicUploads(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
+ }
+ if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeTenantFeedback ||
+ entry.upload.Location == "" || handler.def == nil {
+ t.Fatalf("unexpected upload: %#v handler=%#v", entry.upload, handler.def)
+ }
+ data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
+ if !strings.HasPrefix(data["file_id"].(string), "file_") || data["purpose"] != types.PublicUploadPurposeTenantFeedback {
+ t.Fatalf("unexpected upload response: %#v", data)
+ }
+}
+
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
diff --git a/server/main.go b/server/main.go
index b579157..7ec13f6 100644
--- a/server/main.go
+++ b/server/main.go
@@ -782,6 +782,9 @@ func main() {
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
// Product HTTP API.
mux.HandleFunc(config.ApiPath+"v1/im/tenants/resolve", serveIMTenantResolve)
+ mux.HandleFunc(config.ApiPath+"v1/im/public/uploads", serveIMPublicUploads)
+ mux.HandleFunc(config.ApiPath+"v1/im/tenant-applications", serveIMTenantApplications)
+ mux.HandleFunc(config.ApiPath+"v1/im/tenant-entry-feedback", serveIMTenantEntryFeedback)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes", serveIMVerificationCodes)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
diff --git a/server/media/fs/filesys.go b/server/media/fs/filesys.go
index 144c855..8a56e01 100644
--- a/server/media/fs/filesys.go
+++ b/server/media/fs/filesys.go
@@ -153,6 +153,29 @@ func (fh *fshandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64,
return fh.ServeURL + fname, size, nil
}
+func (fh *fshandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
+ location, err := publicFileLocation(fh.FileUploadDirectory, fdef.Purpose, fdef.ID, fdef.CreatedAt)
+ if err != nil {
+ return "", 0, err
+ }
+ if err = os.MkdirAll(filepath.Dir(location), 0777); err != nil {
+ return "", 0, err
+ }
+ outfile, err := os.Create(location)
+ if err != nil {
+ return "", 0, err
+ }
+ size, err := io.Copy(outfile, file)
+ outfile.Close()
+ if err != nil {
+ os.Remove(location)
+ return "", 0, err
+ }
+ fdef.Location = location
+ fdef.ETag = etagFromPath(location)
+ return location, size, nil
+}
+
// Download processes request for file download.
// The returned ReadSeekCloser must be closed after use.
func (fh *fshandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
@@ -226,6 +249,28 @@ func tenantFileLocation(root string, tenantID types.TenantID, fileID string, cre
createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
+func publicFileLocation(root, purpose, fileID string, createdAt time.Time) (string, error) {
+ purpose = cleanPublicPurpose(purpose)
+ fileID = strings.Trim(fileID, `/\`)
+ if root == "" || purpose == "" || fileID == "" {
+ return "", types.ErrMalformed
+ }
+ if createdAt.IsZero() {
+ createdAt = time.Now().UTC()
+ }
+ return filepath.Join(root, "public", purpose, createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
+}
+
+func cleanPublicPurpose(purpose string) string {
+ purpose = strings.Trim(strings.ToLower(strings.TrimSpace(purpose)), `/\`)
+ return strings.Map(func(r rune) rune {
+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
+ return r
+ }
+ return -1
+ }, purpose)
+}
+
func init() {
store.RegisterMediaHandler(handlerName, &fshandler{})
}
diff --git a/server/media/media.go b/server/media/media.go
index f4e650a..ab66baf 100644
--- a/server/media/media.go
+++ b/server/media/media.go
@@ -9,6 +9,7 @@ import (
"path"
"regexp"
"strings"
+ "time"
"slices"
@@ -45,6 +46,19 @@ type Handler interface {
GetIdFromUrl(url string) types.Uid
}
+type PublicFileDef struct {
+ ID string
+ Purpose string
+ MimeType string
+ CreatedAt time.Time
+ Location string
+ ETag string
+}
+
+type PublicUploader interface {
+ UploadPublic(fdef *PublicFileDef, file io.Reader) (string, int64, error)
+}
+
type AllowedOrigin struct {
Origin string
URL url.URL
diff --git a/server/media/oss/oss.go b/server/media/oss/oss.go
index 0e1b6e0..4aaba39 100644
--- a/server/media/oss/oss.go
+++ b/server/media/oss/oss.go
@@ -262,6 +262,29 @@ func (oh *ossHandler) Upload(fdef *types.FileDef, file io.Reader) (string, int64
return oh.conf.ServeURL + fname, rc.count, nil
}
+func (oh *ossHandler) UploadPublic(fdef *media.PublicFileDef, file io.Reader) (string, int64, error) {
+ key, err := publicObjectKey(fdef.Purpose, fdef.ID, fdef.CreatedAt)
+ if err != nil {
+ return "", 0, err
+ }
+ rc := readerCounter{reader: file}
+ options := []aliyunoss.Option{aliyunoss.CacheControl(oh.conf.CacheControl)}
+ if fdef.MimeType != "" {
+ options = append(options, aliyunoss.ContentType(fdef.MimeType))
+ }
+ if err = oh.bucket.PutObject(key, &rc, options...); err != nil {
+ return "", 0, err
+ }
+ fdef.Location = key
+ fdef.ETag = etagFromLocation(key)
+ if meta, err := oh.bucket.GetObjectDetailedMeta(key); err == nil {
+ if etag := strings.Trim(meta.Get("ETag"), "\""); etag != "" {
+ fdef.ETag = etag
+ }
+ }
+ return key, rc.count, nil
+}
+
// Download is unsupported because OSS objects are served through signed redirects.
func (oh *ossHandler) Download(tenantID types.TenantID, url string) (*types.FileDef, media.ReadSeekCloser, error) {
return nil, nil, types.ErrUnsupported
@@ -308,6 +331,27 @@ func tenantObjectKey(tenantID types.TenantID, fileID string, createdAt time.Time
createdAt.Format("2006"), createdAt.Format("01"), fileID), nil
}
+func publicObjectKey(purpose, fileID string, createdAt time.Time) (string, error) {
+ purpose = cleanPublicPurpose(purpose)
+ if purpose == "" || fileID == "" {
+ return "", types.ErrMalformed
+ }
+ if createdAt.IsZero() {
+ createdAt = time.Now().UTC()
+ }
+ return path.Join("public", purpose, createdAt.Format("2006"), createdAt.Format("01"), strings.Trim(fileID, "/")), nil
+}
+
+func cleanPublicPurpose(purpose string) string {
+ purpose = strings.Trim(strings.ToLower(strings.TrimSpace(purpose)), "/")
+ return strings.Map(func(r rune) rune {
+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
+ return r
+ }
+ return -1
+ }, purpose)
+}
+
func etagFromLocation(location string) string {
hasher := fnv.New128()
hasher.Write([]byte(location))
diff --git a/server/store/store.go b/server/store/store.go
index bcf3cb4..51e1644 100644
--- a/server/store/store.go
+++ b/server/store/store.go
@@ -173,6 +173,52 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri
return configAdp.ThirdPartyConfig(group, keys...)
}
+type TenantEntryPersistenceInterface interface {
+ CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error)
+ CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error)
+ CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error)
+}
+
+type tenantEntryMapper struct{}
+
+var TenantEntry TenantEntryPersistenceInterface
+
+func (tenantEntryMapper) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) {
+ if upload == nil || strings.TrimSpace(upload.FileID) == "" || strings.TrimSpace(upload.Purpose) == "" ||
+ strings.TrimSpace(upload.MimeType) == "" || strings.TrimSpace(upload.Location) == "" || upload.Size <= 0 {
+ return nil, types.ErrMalformed
+ }
+ entryAdp, ok := adp.(adapter.TenantEntryAdapter)
+ if !ok {
+ return nil, types.ErrUnsupported
+ }
+ return entryAdp.PublicUploadCreate(upload)
+}
+
+func (tenantEntryMapper) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) {
+ if application == nil || strings.TrimSpace(application.ApplicationID) == "" ||
+ strings.TrimSpace(application.CreditCode) == "" || strings.TrimSpace(application.IdempotencyKey) == "" {
+ return nil, types.ErrMalformed
+ }
+ entryAdp, ok := adp.(adapter.TenantEntryAdapter)
+ if !ok {
+ return nil, types.ErrUnsupported
+ }
+ return entryAdp.TenantApplicationCreate(application)
+}
+
+func (tenantEntryMapper) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) {
+ if feedback == nil || strings.TrimSpace(feedback.FeedbackID) == "" ||
+ strings.TrimSpace(feedback.Description) == "" || strings.TrimSpace(feedback.IdempotencyKey) == "" {
+ return nil, types.ErrMalformed
+ }
+ entryAdp, ok := adp.(adapter.TenantEntryAdapter)
+ if !ok {
+ return nil, types.ErrUnsupported
+ }
+ return entryAdp.TenantEntryFeedbackCreate(feedback)
+}
+
// GetByCode resolves a tenant by its public enterprise code.
func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
tenantAdp, ok := adp.(adapter.TenantAdapter)
@@ -3693,6 +3739,7 @@ func init() {
Store = storeObj{}
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
+ TenantEntry = tenantEntryMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
diff --git a/server/store/types/tenant_entry.go b/server/store/types/tenant_entry.go
new file mode 100644
index 0000000..455b2bf
--- /dev/null
+++ b/server/store/types/tenant_entry.go
@@ -0,0 +1,52 @@
+package types
+
+import "time"
+
+const (
+ PublicUploadPurposeEnterpriseLicense = "enterprise_license"
+ PublicUploadPurposeTenantFeedback = "tenant_entry_feedback"
+
+ TenantApplicationStatusReceived = "received"
+ TenantEntryFeedbackReceived = "received"
+)
+
+type PublicUpload struct {
+ FileID string
+ Purpose string
+ MimeType string
+ Size int64
+ ETag string
+ Location string
+ ExpiresAt time.Time
+ CreatedAt time.Time
+ UploadedAt time.Time
+}
+
+type TenantApplication struct {
+ ApplicationID string
+ CreditCode string
+ EnterpriseEmail string
+ LicenseFileID string
+ TenantCodeHint string
+ ClientContext map[string]any
+ IdempotencyKey string
+ Status string
+ EstimatedReviewHour int
+ SubmittedAt time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type TenantEntryFeedback struct {
+ FeedbackID string
+ Description string
+ Contact string
+ TenantCodeHint string
+ ScreenshotFileIDs []string
+ ClientContext map[string]any
+ IdempotencyKey string
+ Status string
+ SubmittedAt time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}