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 +}