feat: add safety reporting APIs

This commit is contained in:
richjohnrambo
2026-08-14 00:01:54 +08:00
parent fa75c0f0f9
commit 170bbdca76
10 changed files with 1344 additions and 6 deletions
+376 -2
View File
@@ -40,6 +40,7 @@ const (
imReasonRealNameRequired = "REALNAME_REQUIRED"
imReasonActionToken = "ACTION_TOKEN_INVALID"
imReasonPermissionDenied = "PERMISSION_DENIED"
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
imReasonInternal = "INTERNAL_ERROR"
)
@@ -133,6 +134,26 @@ type imDevicePushTokenRequest struct {
Platform string `json:"platform,omitempty"`
}
type imRiskChallengeConfirmRequest struct {
Decision string `json:"decision"`
}
type imAbuseReportRequest struct {
Target types.AbuseTarget `json:"target"`
ReasonCode string `json:"reason_code"`
Description string `json:"description,omitempty"`
EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"`
}
type imFeedbackRequest struct {
Category string `json:"category"`
Content string `json:"content"`
Contact string `json:"contact,omitempty"`
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
ClientContext map[string]any `json:"client_context,omitempty"`
IncludeDiagnostics bool `json:"include_diagnostics,omitempty"`
}
type imSystemNotificationReadRequest struct {
ReadAt string `json:"read_at,omitempty"`
}
@@ -663,7 +684,8 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if _, ok := authenticateIMHTTPBearer(wrt, req); !ok {
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imURLCheckRequest
@@ -679,11 +701,208 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
"reason": reason,
}
if decision != "allow" {
payload["risk_challenge_id"] = "url_" + shortTokenHash(strings.ToLower(strings.TrimSpace(body.URL)))
challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano()))
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute)
if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{
ChallengeID: challengeID,
UserID: principal.Uid,
Action: "open_url",
Level: riskLevel,
NoticeCode: "url_" + riskLevel,
NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)},
State: types.RiskChallengeStatePending,
ExpiresAt: expiresAt,
}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload["risk_challenge_id"] = challengeID
payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMRiskChallengeConfirm(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
}
challengeID := riskChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"})
return
}
var body imRiskChallengeConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
decision := strings.ToLower(strings.TrimSpace(body.Decision))
var tokenID string
var token string
var expiresAt time.Time
var err error
if decision == "continue" {
token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tokenID, _, _ = strings.Cut(token, ".")
}
challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
payload := riskChallengePayload(challenge)
if decision == "continue" {
payload["risk_action_token"] = token
payload["token_action"] = "risk_continue"
payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type")))
reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
items := make([]map[string]any, 0, len(reasons))
for _, reason := range reasons {
items = append(items, abuseReasonPayload(reason))
}
writeIMSuccess(wrt, req, map[string]any{"items": items})
}
func serveIMAbuseReports(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 imAbuseReportRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
description := strings.TrimSpace(body.Description)
if len([]rune(description)) > 500 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"})
return
}
if len(body.EvidenceFileIDs) > 9 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"})
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{
ReportID: "ar_" + token,
ReporterID: principal.Uid,
TargetType: targetType,
TargetUserID: targetUser,
TargetTopic: targetTopic,
TargetSeq: targetSeq,
ReasonCode: strings.TrimSpace(body.ReasonCode),
Description: description,
EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9),
Snapshot: snapshot,
}, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, abuseReportPayload(report))
}
func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, feedbackConfigPayload(config))
}
func serveIMFeedback(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 imFeedbackRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
content := strings.TrimSpace(body.Content)
if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
token, err := randomIMToken(12)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{
FeedbackID: "fb_" + token,
UserID: principal.Uid,
Category: strings.TrimSpace(body.Category),
Content: content,
Contact: strings.TrimSpace(body.Contact),
AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5),
ClientContext: body.ClientContext,
IncludeDiagnostics: body.IncludeDiagnostics,
}, idempotencyKey)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback))
}
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -5426,6 +5645,57 @@ func devicePushTokenPayload(token *types.DevicePushToken) map[string]any {
}
}
func riskChallengePayload(challenge *types.RiskChallenge) map[string]any {
return map[string]any{
"risk_challenge_id": challenge.ChallengeID,
"action": challenge.Action,
"level": challenge.Level,
"notice_code": challenge.NoticeCode,
"notice_params": challenge.NoticeParams,
"state": challenge.State,
"decision": challenge.Decision,
"expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano),
"updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func abuseReasonPayload(reason types.AbuseReason) map[string]any {
return map[string]any{
"target_type": reason.TargetType,
"reason_code": reason.ReasonCode,
"label": reason.Label,
"requires_description": reason.RequiresDescription,
"allows_evidence": reason.AllowsEvidence,
}
}
func abuseReportPayload(report *types.AbuseReport) map[string]any {
return map[string]any{
"report_id": report.ReportID,
"status": report.Status,
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}
func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any {
return map[string]any{
"categories": config.Categories,
"max_length": config.MaxLength,
"attachment_policy": config.AttachmentPolicy,
"allow_contact": config.AllowContact,
"diagnostics_notice": config.DiagnosticsNotice,
"updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
}
func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any {
return map[string]any{
"feedback_id": feedback.FeedbackID,
"status": feedback.Status,
"created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano),
}
}
func isAllowedIMWorkbenchRoute(route string) bool {
switch route {
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
@@ -6228,6 +6498,110 @@ func securityChallengeIDFromPath(path string) string {
return strings.TrimSpace(challengeID)
}
func riskChallengeIDFromPath(path string) string {
const marker = "/v1/im/security/risk-challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "confirm" {
return ""
}
return strings.TrimSpace(challengeID)
}
func cleanStringList(values []string, limit int) []string {
if limit <= 0 {
limit = len(values)
}
capacity := len(values)
if capacity > limit {
capacity = limit
}
cleaned := make([]string, 0, capacity)
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
cleaned = append(cleaned, value)
if len(cleaned) >= limit {
break
}
}
return cleaned
}
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}
switch targetType {
case "user":
uid := types.ParseUserId(strings.TrimSpace(target.UserID))
if uid.IsZero() || uid == reporter {
return "", 0, "", 0, nil, types.ErrMalformed
}
if store.Users != nil {
if _, err := store.Users.Get(tenantID, uid); err != nil {
return "", 0, "", 0, nil, err
}
}
snapshot["user_id"] = uid.UserId()
return targetType, uid, "", 0, snapshot, nil
case "group":
topic := strings.TrimSpace(target.Topic)
if types.GetTopicCat(topic) != types.TopicCatGrp {
return "", 0, "", 0, nil, types.ErrMalformed
}
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
return "", 0, "", 0, nil, err
}
snapshot["topic"] = topic
return targetType, 0, topic, 0, snapshot, nil
case "message":
topic := strings.TrimSpace(target.Topic)
seq, err := strconv.Atoi(strings.TrimSpace(target.Seq))
if topic == "" || seq <= 0 || err != nil {
return "", 0, "", 0, nil, types.ErrMalformed
}
if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil {
return "", 0, "", 0, nil, err
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
if err != nil {
return "", 0, "", 0, nil, err
}
snapshot["topic"] = topic
snapshot["seq"] = seq
snapshot["from"] = msg.From
snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano)
return targetType, 0, topic, seq, snapshot, nil
default:
return "", 0, "", 0, nil, types.ErrMalformed
}
}
func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error {
if store.Subs == nil {
return types.ErrPermissionDenied
}
sub, err := store.Subs.Get(tenantID, topic, uid, false)
if err != nil {
return err
}
if sub == nil || !sub.ModeGiven.IsReader() {
return types.ErrPermissionDenied
}
return nil
}
func systemNotificationIDFromPath(path string) string {
const marker = "/v1/im/system-notifications/"
idx := strings.Index(path, marker)