package main import ( "errors" "net/http" "strconv" "strings" "time" "github.com/tinode/chat/server/moderation" "github.com/tinode/chat/server/push" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) type cultivateUsersRequest struct { UserIDs []int64 `json:"user_ids"` } type cultivateRelationsRequest struct { ViewerID int64 `json:"viewer_id"` PeerIDs []int64 `json:"peer_ids"` } type cultivateFilesRequest struct { UserID int64 `json:"user_id"` ReferenceID int64 `json:"reference_id"` FileIDs []string `json:"file_ids"` } type cultivateReviewRequest struct { ObjectType string `json:"object_type"` ObjectID int64 `json:"object_id"` ActorID int64 `json:"actor_id"` Text string `json:"text"` TraceID string `json:"trace_id"` } type cultivateEventRequest struct { EventID string `json:"event_id"` EventType string `json:"event_type"` ActorID int64 `json:"actor_id"` TargetUserID int64 `json:"target_user_id"` Title string `json:"title"` Summary string `json:"summary"` Action map[string]any `json:"action"` Payload map[string]any `json:"payload"` } func serveInternalCultivate(w http.ResponseWriter, r *http.Request, prefix string) { if r.Method != http.MethodPost { writeIMError(w, r, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if !authorizeInternalAPI(w, r) { return } parts := splitInternalPath(strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/")) if len(parts) != 4 || parts[0] != "tenants" { writeIMError(w, r, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return } tenant, ok := resolveCultivateTenant(w, r, parts[1]) if !ok { return } switch parts[2] + "/" + parts[3] { case "users/batch": serveCultivateUsers(w, r, tenant.ID) case "relations/check": serveCultivateRelations(w, r, tenant.ID) case "files/validate": serveCultivateFiles(w, r, tenant.ID) case "moderation/review": serveCultivateReview(w, r, tenant) case "events/publish": serveCultivateEvent(w, r, tenant.ID) default: writeIMError(w, r, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) } } func resolveCultivateTenant(w http.ResponseWriter, r *http.Request, value string) (*types.Tenant, bool) { if id, err := strconv.ParseInt(value, 10, 64); err == nil && id > 0 && store.Tenants != nil { tenant, lookupErr := store.Tenants.GetByID(types.TenantID(id)) if lookupErr == nil && tenant != nil && tenant.IsActive() { return tenant, true } writeIMError(w, r, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return nil, false } return resolveInternalTenant(w, r, value) } func serveCultivateUsers(w http.ResponseWriter, r *http.Request, tenantID types.TenantID) { var body cultivateUsersRequest if !decodeIMJSONBody(w, r, &body) { return } if len(body.UserIDs) == 0 || len(body.UserIDs) > 200 { writeIMError(w, r, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } items := make([]map[string]any, 0, len(body.UserIDs)) for _, rawID := range body.UserIDs { if rawID <= 0 { continue } uid := store.EncodeUid(rawID) user, err := store.Users.Get(tenantID, uid) if err != nil || user == nil { continue } item := map[string]any{"user_id": rawID, "state": user.State.String(), "nickname": "", "avatar_file_id": "", "youth_mode": false} if profile, err := store.Profiles.Get(tenantID, uid); err == nil && profile != nil { item["nickname"], item["avatar_file_id"] = profile.Nickname, profile.AvatarFileID } if settings, err := store.Security.GetYouthMode(tenantID, uid); err == nil && settings != nil { item["youth_mode"] = settings.Enabled } items = append(items, item) } writeIMSuccess(w, r, map[string]any{"items": items}) } func serveCultivateRelations(w http.ResponseWriter, r *http.Request, tenantID types.TenantID) { var body cultivateRelationsRequest if !decodeIMJSONBody(w, r, &body) { return } if body.ViewerID <= 0 || len(body.PeerIDs) > 500 { writeIMError(w, r, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } viewer := store.EncodeUid(body.ViewerID) items := make([]map[string]any, 0, len(body.PeerIDs)) for _, rawID := range body.PeerIDs { if rawID <= 0 || rawID == body.ViewerID { continue } peer := store.EncodeUid(rawID) ctx, err := store.Relations.GetRelationshipContext(tenantID, viewer, peer, viewer.P2PName(peer)) if err != nil || ctx == nil { continue } items = append(items, map[string]any{"peer_id": rawID, "relationship": ctx.Relationship, "can_send": ctx.CanSend}) } youth := false if settings, err := store.Security.GetYouthMode(tenantID, viewer); err == nil && settings != nil { youth = settings.Enabled } writeIMSuccess(w, r, map[string]any{"items": items, "viewer_youth_mode": youth}) } func serveCultivateFiles(w http.ResponseWriter, r *http.Request, tenantID types.TenantID) { var body cultivateFilesRequest if !decodeIMJSONBody(w, r, &body) { return } if body.UserID < 0 || len(body.FileIDs) == 0 || len(body.FileIDs) > 20 { writeIMError(w, r, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } uid := store.EncodeUid(body.UserID) items := make([]map[string]any, 0, len(body.FileIDs)) fileIDs := make([]string, 0, len(body.FileIDs)) for _, supplied := range body.FileIDs { fileID := normalizeIMUploadedFileID(supplied) file, err := store.Files.Get(tenantID, fileID) owner := types.ParseUid(fileOwner(file)) if owner.IsZero() { owner = types.ParseUserId(fileOwner(file)) } if err != nil || file == nil || file.Status != types.UploadCompleted || (!uid.IsZero() && owner != uid) { writeIMError(w, r, http.StatusUnprocessableEntity, 30000, imReasonMalformedRequest, "invalid file", map[string]any{"file_id": supplied}) return } fileIDs = append(fileIDs, fileID) items = append(items, map[string]any{"file_id": fileID, "mime_type": file.MimeType, "size": file.Size, "review_status": cultivateFileReviewStatus(tenantID, fileID)}) } if body.ReferenceID > 0 { for index, fileID := range fileIDs { if err := store.Files.LinkAttachments(tenantID, cultivateFileReferenceTopic(body.ReferenceID, index), types.ZeroUid, []string{fileID}); err != nil { writeIMError(w, r, http.StatusInternalServerError, 90000, imReasonInternal, "failed to retain file", nil) return } } } writeIMSuccess(w, r, map[string]any{"items": items}) } func cultivateFileReferenceTopic(referenceID int64, index int) string { return "cvp" + strconv.FormatInt(referenceID, 36) + "x" + strconv.FormatInt(int64(index), 36) } func fileOwner(file *types.FileDef) string { if file == nil { return "" } return file.User } func cultivateFileReviewStatus(tenantID types.TenantID, fileID string) string { uid := types.ParseUid(fileID) if uid.IsZero() { return "unconfigured" } row, err := store.InternalAdminQueries.QueryOne(tenantID, `SELECT COUNT(*) AS total, SUM(status='risky') AS risky_count, SUM(status IN ('pending','running','failed')) AS pending_count FROM im_file_review_tasks WHERE file_id=?`, []any{store.DecodeUid(uid)}) if err != nil { return "unconfigured" } total, _ := numericID(row["total"]) risky, _ := numericID(row["risky_count"]) pending, _ := numericID(row["pending_count"]) if risky > 0 { return "risky" } if pending > 0 { return "pending" } if total > 0 { return "clean" } return "unconfigured" } func serveCultivateReview(w http.ResponseWriter, r *http.Request, tenant *types.Tenant) { var body cultivateReviewRequest if !decodeIMJSONBody(w, r, &body) { return } if body.ActorID <= 0 || body.ObjectID <= 0 { writeIMError(w, r, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } scene := moderation.SceneFeedPost if body.ObjectType == "comment" { scene = moderation.SceneFeedComment } else if body.ObjectType != "post" { writeIMError(w, r, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "object_type"}) return } result, err := moderation.Review(r.Context(), moderation.Request{TenantID: tenant.ID, TenantCode: tenant.Code, Scene: scene, ActorID: store.EncodeUid(body.ActorID), Text: body.Text, TraceID: body.TraceID}) if result == nil { result = &moderation.Result{Action: moderation.ActionAuditOnly} } data := map[string]any{"request_id": result.RequestID, "action": result.Action, "risk_level": result.RiskLevel, "risk_labels": result.RiskLabels, "provider": result.Provider, "provider_request_id": result.ProviderRequestID} if errors.Is(err, moderation.ErrUnavailable) { writeIMError(w, r, http.StatusServiceUnavailable, 50001, imReasonInternal, "content review unavailable", data) return } // A moderation rejection is a valid review decision, not a transport error. writeIMSuccess(w, r, data) } func serveCultivateEvent(w http.ResponseWriter, r *http.Request, tenantID types.TenantID) { var body cultivateEventRequest if !decodeIMJSONBody(w, r, &body) { return } if body.EventID == "" || body.EventType == "" || body.TargetUserID <= 0 { writeIMError(w, r, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } target, actor := store.EncodeUid(body.TargetUserID), store.EncodeUid(body.ActorID) if body.Title != "" { if err := createIMSystemNotification(tenantID, target, stableIMEventID(body.EventID, "notification"), body.EventType, body.Title, body.Summary, types.KVMap(body.Action)); err != nil { if errors.Is(err, types.ErrDuplicate) { writeIMSuccess(w, r, map[string]any{"event_id": body.EventID, "duplicate": true}) return } writeIMError(w, r, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } } if err := enqueueIMBusinessEvent(tenantID, body.EventID, "user", target.UserId(), actor, body.EventType, body.Payload); err != nil { writeIMError(w, r, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } if body.Title != "" { sendPush(&push.Receipt{TenantID: tenantID, To: map[types.Uid]push.Recipient{target: {}}, Payload: push.Payload{ What: push.ActMsg, Topic: "sys", Timestamp: time.Now().UTC(), ContentType: "application/json", Content: map[string]any{"event_id": body.EventID, "event_type": body.EventType, "title": body.Title, "summary": body.Summary, "action": body.Action}, }}) } writeIMSuccess(w, r, map[string]any{"event_id": body.EventID}) }