diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 6899154..7f0f8e2 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -943,7 +943,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 +- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`;注销账号使用 `{"scene":"delete_account","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 - 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。 - 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 @@ -969,7 +969,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 - 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 -- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 +- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,Body `{"reason":"...","attachments":[]}`(`reason` 必填、≤500 字;`attachments` 最多 9 个 file_id),Bearer 即可,当前无需二次身份验证;不复用本页保存按钮。 ### 1.10 实名认证(安卓).png @@ -3179,9 +3179,29 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | -| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/security/realname/appeals` | `{"reason":"证件变更需要更正实名信息","attachments":["file_1"]}` | `{"appeal_id":"rna_1","reason":"...","attachments":["file_1"],"status":"pending"}`;当前无需二次身份验证(如后续启用,将复用 1.7 的 action_token 机制并在此处更新) | | `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | | `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | +| `POST /v1/im/me/password` | `{"old_password":"OldPass123","new_password":"NewPass123"}` | `{"status":"ok"}`;旧密码错误返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`;新密码不满足策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}` | +| `POST /v1/im/security/challenges` 注销账号发码 | Header `Idempotency-Key`;Body `{"scene":"delete_account","channel":"sms"}` | `{"challenge_id":"sec_chg_9","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges/{id}/verify` 注销账号验证 | `{"code":"123456"}` | `{"action_token":"del_token","action":"delete_account","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/me/deletion` | Header `X-Action-Token: del_token`;Body `{}` | `{"status":"ok"}` | + +#### 10.22.2 账号安全:修改密码与注销账号 + +**修改密码 `POST /v1/im/me/password`(已实现):** + +- 已登录(Bearer)用户提交 `old_password`、`new_password`;两者必填,缺一返回 `400 MALFORMED_REQUEST`。 +- 服务端先用旧密码走 `basic` 认证校验,失败返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`,不泄露账号状态。 +- 新密码不满足密码策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}`。 +- 成功后服务端递增 `auth_version` 并撤销该用户全部在线 Session(广播 `password_changed`);包括当前请求在内的所有旧 Token 全部失效,客户端收到成功响应后必须清理本租户凭证并跳转登录页。 + +**注销账号 `POST /v1/im/me/deletion`(已实现):** + +- 两步挑战复用 1.7 敏感操作基建:`POST /v1/im/security/challenges`(Body `{"scene":"delete_account","channel":"sms"}`,发码到账号绑定手机号)→ `POST /v1/im/security/challenges/{id}/verify`(返回一次性 `action_token`)→ `POST /v1/im/me/deletion`(Header `X-Action-Token` 携带)。 +- 服务端配置 `permanent_accounts=true` 时拒绝注销:HTTP `403` `{"code":30040,"reason":"FORBIDDEN","message":"account deletion is disabled"}`。 +- 执行成功为**软删除**:撤销全部认证记录(含登录密码)、踢出所有在线 Session(`reason=user_deleted`)、退出并停用其拥有的群与 P2P 会话、向关注该用户的好友下发 `gone` 在线状态;账号不再可登录。客户端收到成功响应后清理本地数据并返回企业入口。 +- 挑战阶段限流与错误码同 1.7(`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`);action token 缺失/无效返回 HTTP `403` `{"code":30042,"reason":"ACTION_TOKEN_INVALID"}`。 ## 11. 反诈安全、举报与意见反馈 diff --git a/docs/对接文档最新/IM-前后端对接文档.md b/docs/对接文档最新/IM-前后端对接文档.md index 1ea6383..72711df 100644 --- a/docs/对接文档最新/IM-前后端对接文档.md +++ b/docs/对接文档最新/IM-前后端对接文档.md @@ -909,7 +909,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 +- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`;群主转让使用 `{"scene":"group_owner_transfer","channel":"sms"}`;解散群聊使用 `{"scene":"group_dissolve","channel":"sms"}`;注销账号使用 `{"scene":"delete_account","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 - 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 - 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。 - 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 @@ -935,7 +935,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 - 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 -- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 +- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,Body `{"reason":"...","attachments":[]}`(`reason` 必填、≤500 字;`attachments` 最多 9 个 file_id),Bearer 即可,当前无需二次身份验证;不复用本页保存按钮。 ### 1.10 实名认证(安卓).png @@ -983,7 +983,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | | `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | | `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | -| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` | +| `POST /v1/im/auth/logout` | `{}`(可选 `device_id`;或 Header `X-Device-ID`/`X-IM-Device-ID`) | `{"status":"ok","device_id":"...","push_unbound":true}`;当前 Token 写入 Redis 撤销集合(TTL=剩余有效期),仅当前设备失效,不影响其他设备;带 `device_id` 时同步解绑该设备推送 | | `GET /v1/im/app/releases/latest?platform=ios\|android\|web¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"web","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | ## 2. 消息首页、全局导航与系统通知 @@ -2928,7 +2928,7 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} **状态:菜单纯客户端;数据接口部分新增。** - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 退出:`POST /v1/im/auth/logout` 将当前登录 Token 的 SHA-256 摘要写入 Redis 撤销集合,TTL 与 Token 剩余有效期一致,不影响同账号其他设备。成功后关闭 WS、清安全存储和本租户用户缓存;请求须带 `Authorization: Bearer ` 和 `Idempotency-Key`。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 ### 10.8 设置(安卓).png @@ -3096,9 +3096,29 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} | `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | | `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | | `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | -| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/security/realname/appeals` | `{"reason":"证件变更需要更正实名信息","attachments":["file_1"]}` | `{"appeal_id":"rna_1","reason":"...","attachments":["file_1"],"status":"pending"}`;当前无需二次身份验证(如后续启用,将复用 1.7 的 action_token 机制并在此处更新) | | `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | | `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | +| `POST /v1/im/me/password` | `{"old_password":"OldPass123","new_password":"NewPass123"}` | `{"status":"ok"}`;旧密码错误返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`;新密码不满足策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}` | +| `POST /v1/im/security/challenges` 注销账号发码 | Header `Idempotency-Key`;Body `{"scene":"delete_account","channel":"sms"}` | `{"challenge_id":"sec_chg_9","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges/{id}/verify` 注销账号验证 | `{"code":"123456"}` | `{"action_token":"del_token","action":"delete_account","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/me/deletion` | Header `X-Action-Token: del_token`;Body `{}` | `{"status":"ok"}` | + +#### 10.22.2 账号安全:修改密码与注销账号 + +**修改密码 `POST /v1/im/me/password`(已实现):** + +- 已登录(Bearer)用户提交 `old_password`、`new_password`;两者必填,缺一返回 `400 MALFORMED_REQUEST`。 +- 服务端先用旧密码走 `basic` 认证校验,失败返回 HTTP `401`:`{"code":30001,"reason":"AUTH_REQUIRED","message":"invalid credentials"}`,不泄露账号状态。 +- 新密码不满足密码策略返回 HTTP `400`:`{"code":30040,"reason":"PASSWORD_POLICY_VIOLATION"}`。 +- 成功后服务端递增 `auth_version` 并撤销该用户全部在线 Session(广播 `password_changed`);包括当前请求在内的所有旧 Token 全部失效,客户端收到成功响应后必须清理本租户凭证并跳转登录页。 + +**注销账号 `POST /v1/im/me/deletion`(已实现):** + +- 两步挑战复用 1.7 敏感操作基建:`POST /v1/im/security/challenges`(Body `{"scene":"delete_account","channel":"sms"}`,发码到账号绑定手机号)→ `POST /v1/im/security/challenges/{id}/verify`(返回一次性 `action_token`)→ `POST /v1/im/me/deletion`(Header `X-Action-Token` 携带)。 +- 服务端配置 `permanent_accounts=true` 时拒绝注销:HTTP `403` `{"code":30040,"reason":"FORBIDDEN","message":"account deletion is disabled"}`。 +- 执行成功为**软删除**:撤销全部认证记录(含登录密码)、踢出所有在线 Session(`reason=user_deleted`)、退出并停用其拥有的群与 P2P 会话、向关注该用户的好友下发 `gone` 在线状态;账号不再可登录。客户端收到成功响应后清理本地数据并返回企业入口。 +- 挑战阶段限流与错误码同 1.7(`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`);action token 缺失/无效返回 HTTP `403` `{"code":30042,"reason":"ACTION_TOKEN_INVALID"}`。 ## 11. 反诈安全、举报与意见反馈 diff --git a/server/im_http.go b/server/im_http.go index 0975048..bd1dc97 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1704,6 +1704,155 @@ func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback)) } +type imMePasswordChangeRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +// serveIMMePasswordChange lets an authenticated user change own password by +// presenting the current one. All sessions are evicted afterwards. +func serveIMMePasswordChange(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imMePasswordChangeRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + if strings.TrimSpace(body.OldPassword) == "" || strings.TrimSpace(body.NewPassword) == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + basicAuth := store.Store.GetLogicalAuthHandler("basic") + if basicAuth == nil || !basicAuth.IsInitialized() { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + login, authLevel, _, _, err := store.Users.GetAuthRecord(principal.TenantID, principal.Uid) + if err != nil || login == "" { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + authCtx := auth.AuthContext{TenantID: principal.TenantID, RemoteAddr: req.RemoteAddr} + if _, _, err = basicAuth.Authenticate(authCtx, []byte(login+":"+body.OldPassword)); err != nil { + writeIMError(wrt, req, http.StatusUnauthorized, 30001, imReasonAuthRequired, "invalid credentials", nil) + return + } + if _, err = basicAuth.UpdateRecord(authCtx, &auth.Rec{ + TenantID: principal.TenantID, + Uid: principal.Uid, + AuthLevel: authLevel, + }, []byte(":"+body.NewPassword)); err != nil { + if err == types.ErrPolicy { + writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil) + return + } + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + now := time.Now().UTC().Round(time.Millisecond) + if store.Security != nil { + if _, err = store.Security.BumpAuthVersion(principal.TenantID, principal.Uid, now); err != nil { + logs.Warn.Println("im me password change: bump auth version failed", err) + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + } + if globals.sessionStore != nil { + globals.sessionStore.EvictUser(principal.Uid, "") + } + publishRealtimeEvictUser(principal.TenantID, principal.Uid, "password_changed", "") + writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) +} + +// serveIMMeDeletion deletes the authenticated user's account. Requires a +// confirmed "delete_account" sensitive-action token obtained through +// POST /v1/im/security/challenges + POST /v1/im/security/challenges/{id}. +func serveIMMeDeletion(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + if globals.permanentAccounts { + writeIMError(wrt, req, http.StatusForbidden, 30040, imReasonForbidden, "account deletion is disabled", nil) + return + } + if !consumeIMActionTokenHeader(wrt, req, principal, "delete_account") { + return + } + tenantID, uid := principal.TenantID, principal.Uid + if state, err := userGetState(tenantID, uid); err != nil || state == types.StateDeleted { + logs.Warn.Println("im me deletion: user is missing or already deleted", err) + writeIMError(wrt, req, http.StatusNotFound, 20004, imReasonResourceNotFound, "user not found", nil) + return + } + + // Disable all authenticators. + for _, name := range store.Store.GetAuthNames() { + hdl := store.Store.GetLogicalAuthHandler(name) + if !hdl.IsInitialized() { + continue + } + if err := hdl.DelRecords(tenantID, uid); err != nil { + // This could be completely benign, i.e. authenticator exists but not used. + logs.Warn.Println("im me deletion: failed to delete auth record", uid.UserId(), name, err) + if storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrUnsupported { + writeIMError(wrt, req, http.StatusForbidden, 30040, imReasonForbidden, "account cannot be deleted", nil) + return + } + } + } + + // Terminate all sessions. + if globals.sessionStore != nil { + globals.sessionStore.EvictUser(uid, "", "user_deleted") + } + publishRealtimeEvictUser(tenantID, uid, "user_deleted", "") + // Remove user from cache and announce to cluster that the user is deleted. + usersRemoveUser(tenantID, uid) + + // Stop topics where the user is the owner and p2p topics. + done := make(chan bool) + globals.hub.unreg <- &topicUnreg{tenantID: tenantID, forUser: uid, del: false, done: done} + <-done + + // Notify users of interest that the user is gone. + if uoi, err := store.Users.GetSubs(tenantID, uid); err == nil { + presUsersOfInterestOffline(tenantID, uid, uoi, "gone") + } else { + logs.Warn.Println("im me deletion: failed to send notifications to users", err) + } + // Notify subscribers of the group topics where the user was the owner. + if ownTopics, err := store.Users.GetOwnTopics(tenantID, uid); err == nil { + for _, topicName := range ownTopics { + if subs, err := store.Topics.GetSubs(tenantID, topicName, nil); err == nil { + presSubsOfflineOffline(tenantID, topicName, types.TopicCatGrp, subs, "gone", &presParams{}, "") + } else { + logs.Warn.Println("im me deletion: failed to notify topic subscribers", err, topicName) + } + } + } else { + logs.Warn.Println("im me deletion: failed to send notifications to owned topics", err) + } + + // Delete user's records from the database (soft delete). + if err := store.Users.Delete(tenantID, uid, false); err != nil { + logs.Warn.Println("im me deletion: failed to delete user", err) + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) +} + func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -6842,6 +6991,8 @@ func defaultIMActionTarget(action, fallback string) string { return "youth_mode" case "group_owner_transfer", "group_dissolve": return action + case "delete_account": + return "account" default: return fallback } @@ -8516,7 +8667,7 @@ func currentUserAccountPayload(tenantID types.TenantID, uid types.Uid) (map[stri func validIMSecurityChallengeScene(scene string) bool { switch strings.TrimSpace(scene) { case "set_independent_password", "enable_youth_mode", "close_youth_mode", "reset_independent_password", - "group_owner_transfer", "group_dissolve": + "group_owner_transfer", "group_dissolve", "delete_account": return true default: return false @@ -10599,6 +10750,7 @@ func cleanStringList(values []string, limit int) []string { func isAllowedPublicUploadPurpose(purpose string) bool { switch strings.TrimSpace(purpose) { case types.PublicUploadPurposeEnterpriseLicense, types.PublicUploadPurposeTenantFeedback, + types.PublicUploadPurposeFeedback, types.PublicUploadPurposeUserAvatar, types.PublicUploadPurposeGroupAvatar: return true @@ -10615,7 +10767,7 @@ func isAllowedPublicUploadMime(purpose, mimeType string) bool { strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/webp") || mimeType == "application/pdf" - case types.PublicUploadPurposeTenantFeedback: + case types.PublicUploadPurposeTenantFeedback, types.PublicUploadPurposeFeedback: return strings.HasPrefix(mimeType, "image/jpeg") || strings.HasPrefix(mimeType, "image/png") || strings.HasPrefix(mimeType, "image/webp") diff --git a/server/main.go b/server/main.go index 7884bd4..d6f893c 100644 --- a/server/main.go +++ b/server/main.go @@ -919,6 +919,8 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe) mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile) mux.HandleFunc(config.ApiPath+"v1/im/me/privacy", serveIMMePrivacy) + mux.HandleFunc(config.ApiPath+"v1/im/me/password", serveIMMePasswordChange) + mux.HandleFunc(config.ApiPath+"v1/im/me/deletion", serveIMMeDeletion) mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/enable", serveIMYouthModeEnable) mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode/disable", serveIMYouthModeDisable) mux.HandleFunc(config.ApiPath+"v1/im/settings/youth-mode", serveIMYouthMode) diff --git a/server/store/types/tenant_entry.go b/server/store/types/tenant_entry.go index 596d86b..8e4443a 100644 --- a/server/store/types/tenant_entry.go +++ b/server/store/types/tenant_entry.go @@ -5,6 +5,7 @@ import "time" const ( PublicUploadPurposeEnterpriseLicense = "enterprise_license" PublicUploadPurposeTenantFeedback = "tenant_entry_feedback" + PublicUploadPurposeFeedback = "feedback" PublicUploadPurposeUserAvatar = "user_avatar" PublicUploadPurposeGroupAvatar = "group_avatar"