package main import ( "fmt" "net/http" "strconv" "strings" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) type internalQueryFilter struct { Param string Column string Op string Kind string } type internalQuerySpec struct { Select string From string DefaultSort string Filters []internalQueryFilter DetailKey string ScopeColumn string } var internalGroupResourceSpecs = map[string]*internalQuerySpec{ "join-requests": { Select: "SELECT id,request_id,topic,applicant_user_id,inviter_user_id,source,message,status,reject_reason,handled_by,handled_at,expires_at,version,created_at,updated_at", From: "FROM im_group_join_requests", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"applicant_user_id", "applicant_user_id", "=", "uid"}}, }, "invitations": { Select: "SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,expires_at,handled_at,created_at,updated_at", From: "FROM im_group_invitations", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"target_user_id", "target_user_id", "=", "uid"}, {"inviter_user_id", "inviter_user_id", "=", "uid"}}, }, "announcements": { Select: "SELECT id,announcement_id,topic,author_user_id,content,status,version,published_at,deleted_by,deleted_at,created_at,updated_at", From: "FROM im_group_announcements", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", Filters: []internalQueryFilter{{"status", "status", "=", "string"}}, }, "mutes": { Select: "SELECT id,topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at", From: "FROM im_group_mutes", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"user_id", "user_id", "=", "uid"}}, }, "admin-logs": { Select: "SELECT id,log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key,created_at", From: "FROM im_group_admin_logs", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", Filters: []internalQueryFilter{{"action", "action", "=", "string"}, {"operator_id", "operator_id", "=", "uid"}}, }, "files": { Select: "SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,created_at,updated_at", From: "FROM im_group_files", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"file_type", "file_type", "=", "string"}, {"file_name", "file_name", "LIKE", "string"}, {"uploader_id", "uploader_id", "=", "uid"}}, }, } var internalTenantResourceSpecs = map[string]*internalQuerySpec{ "audit-logs": { Select: "SELECT id,request_id,actor_user_id,action,target_type,target_id,ip,user_agent,detail,created_at", From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"actor_user_id", "actor_user_id", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, }, "realname-verifications": { Select: "SELECT id,user_id,submission_id,status,masked_name AS real_name,masked_id_number AS id_card_no_masked,review_reason AS reject_reason,verified_at AS reviewed_at,created_at,updated_at", From: "FROM im_realname_verifications", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"status", "status", "=", "string"}, {"real_name", "masked_name", "LIKE", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, }, "abuse-reports": { Select: "SELECT id,report_id,reporter_id AS reporter_user_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at", From: "FROM im_abuse_reports", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"reporter_user_id", "reporter_id", "=", "uid"}, {"target_type", "target_type", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, }, "feedback-submissions": { Select: "SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids AS file_ids,client_context,include_diagnostics,status,created_at,updated_at", From: "FROM im_feedback_submissions", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"category", "category", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, }, "feedback-config": { Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at", From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", }, "mailbox-config": { Select: "SELECT id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at", From: "FROM im_mailbox_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", }, "abuse-reasons": { Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at", From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}}, }, "app-releases": { Select: "SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at", From: "FROM im_app_releases", DefaultSort: "ORDER BY id DESC", DetailKey: "id", }, "workbench/apps": { Select: "SELECT id,code AS app_code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at", From: "FROM im_workbench_apps", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", }, } var internalOutboxResourceSpecs = map[string]*internalQuerySpec{ "business-events": { Select: "SELECT id,event_id,target_type,target,actor_user_id,event_type,payload,status,attempts,next_retry_at,delivered_at,created_at,updated_at", From: "FROM im_business_events_outbox", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"event_type", "event_type", "=", "string"}, {"status", "status", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target", "target", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, }, "group-events": { Select: "SELECT id,event_id,topic,actor_user_id,event_type,payload,status,attempts,next_retry_at,delivered_at,created_at,updated_at", From: "FROM im_group_events_outbox", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"event_type", "event_type", "=", "string"}, {"status", "status", "=", "string"}, {"topic", "topic", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, }, } var internalOrgResourceSpecs = map[string]*internalQuerySpec{ "departments": { Select: "SELECT d.id,d.department_id,d.parent_id,d.name,d.sort_order,d.version,d.state,d.created_at,d.updated_at,(SELECT COUNT(*) FROM im_org_departments child WHERE child.parent_id=d.department_id AND child.state='active') AS child_count,(SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id) AS member_count", From: "FROM im_org_departments d", DefaultSort: "ORDER BY d.sort_order ASC,d.id ASC", Filters: []internalQueryFilter{{"department_id", "d.department_id", "=", "string"}, {"parent_id", "d.parent_id", "=", "string"}, {"name", "d.name", "LIKE", "string"}, {"state", "d.state", "=", "string"}}, }, "members": { Select: "SELECT m.id,m.department_id,COALESCE(d.name,'') AS department_name,m.user_id,m.display_name,m.position,m.avatar_file_id,m.mobile,m.email,m.employment_state,m.contactable,m.sort_order,m.version,m.created_at,m.updated_at", From: "FROM im_org_members m LEFT JOIN im_org_departments d ON d.department_id=m.department_id", DefaultSort: "ORDER BY m.sort_order ASC,m.id ASC", Filters: []internalQueryFilter{{"department_id", "m.department_id", "=", "string"}, {"user_id", "m.user_id", "=", "uid"}, {"q", "m.display_name", "LIKE", "string"}, {"employment_state", "m.employment_state", "=", "string"}}, }, } func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, scope string) { spec, ok := internalAdminQuerySpec(resource) if !ok { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return } query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, scope) if err != nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } result, err := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage, }) } func serveInternalAdminResourceDetail(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, resource, id string) { spec, ok := internalAdminQuerySpec(resource) if !ok || spec.DetailKey == "" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return } query := spec.Select + " " + spec.From + " WHERE " + spec.DetailKey + "=?" row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{id}) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row)) } func serveInternalDashboardSummary(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) { summary := map[string]any{} queries := map[string]string{ "dead_kafka": "SELECT COUNT(*) AS count FROM im_kafka_dead_letters WHERE status='dead'", "pending_business": "SELECT COUNT(*) AS count FROM im_business_events_outbox WHERE status='pending'", "pending_group": "SELECT COUNT(*) AS count FROM im_group_events_outbox WHERE status='pending'", "pending_realname": "SELECT COUNT(*) AS count FROM im_realname_verifications WHERE status='pending'", "received_abuse": "SELECT COUNT(*) AS count FROM im_abuse_reports WHERE status='received'", "received_feedback": "SELECT COUNT(*) AS count FROM im_feedback_submissions WHERE status='received'", "active_groups": "SELECT COUNT(*) AS count FROM im_group_settings WHERE group_status='active'", "suspended_users": "SELECT COUNT(*) AS count FROM im_users WHERE state=10", } for key, query := range queries { row, err := store.InternalAdminQueries.QueryOne(tenantID, query, nil) if err != nil { summary[key] = nil continue } summary[key] = row["count"] } writeIMSuccess(wrt, req, summary) } func serveInternalAbuseTargetQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, reportID string) { row, err := store.InternalAdminQueries.QueryOne(tenantID, "SELECT id,report_id,target_type,target_user_id,target_topic,target_seq,snapshot FROM im_abuse_reports WHERE report_id=?", []any{strings.TrimSpace(reportID)}) if err != nil { writeInternalQueryError(wrt, req, err) return } data := normalizeInternalAdminRowForAPI(row) switch fmt.Sprint(row["target_type"]) { case "user": if id, ok := numericID(row["target_user_id"]); ok && id > 0 { if user, getErr := store.InternalAdminQueries.QueryOne(tenantID, usersInternalQuerySpec().Select+" "+usersInternalQuerySpec().From+" WHERE u.id=?", []any{id}); getErr == nil { data["target"] = normalizeInternalAdminRowForAPI(user) } } case "message": topic := fmt.Sprint(row["target_topic"]) seq, _ := numericID(row["target_seq"]) if topic != "" && seq > 0 { if msg, getErr := store.Messages.GetOne(tenantID, topic, seq); getErr == nil { data["target"] = map[string]any{"topic": msg.Topic, "seq_id": msg.SeqId, "from": msg.From, "head": msg.Head, "content": msg.Content, "created_at": msg.CreatedAt} } } case "group": topic := fmt.Sprint(row["target_topic"]) if topic != "" { if group, getErr := store.InternalAdminQueries.QueryOne(tenantID, groupsInternalQuerySpec().Select+" "+groupsInternalQuerySpec().From+" WHERE t.name=?", []any{topic}); getErr == nil { data["target"] = normalizeInternalAdminRowForAPI(group) } } } writeIMSuccess(wrt, req, data) } func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) { switch resource { case "users": return usersInternalQuerySpec(), true case "user_devices": return &internalQuerySpec{Select: "SELECT id,deviceid AS device_id,platform,lastseen AS last_seen,lang", From: "FROM im_devices", DefaultSort: "ORDER BY lastseen DESC", ScopeColumn: "userid", Filters: []internalQueryFilter{{"device_id", "deviceid", "LIKE", "string"}, {"platform", "platform", "=", "string"}}}, true case "groups": return groupsInternalQuerySpec(), true case "file_links": return &internalQuerySpec{Select: "SELECT id,topic,msgid AS seq_id,userid AS user_id,createdat AS created_at", From: "FROM im_filemsglinks", DefaultSort: "ORDER BY id DESC", ScopeColumn: "fileid"}, true case "files": return &internalQuerySpec{Select: "SELECT id,id AS file_id,userid AS user_id,mimetype AS mime_type,size AS file_size,location,etag,status,createdat AS created_at,updatedat AS updated_at", From: "FROM im_fileuploads", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"file_id", "id", "=", "int"}, {"user_id", "userid", "=", "uid"}, {"status", "status", "=", "int"}, {"mime_type", "mimetype", "LIKE", "string"}, {"created_at_start", "createdat", ">=", "time"}, {"created_at_end", "createdat", "<=", "time"}}}, true } if strings.HasPrefix(resource, "group_") { spec := internalGroupResourceSpecs[strings.TrimPrefix(resource, "group_")] return spec, spec != nil } if strings.HasPrefix(resource, "outbox_") { spec := internalOutboxResourceSpecs[strings.TrimPrefix(resource, "outbox_")] return spec, spec != nil } if strings.HasPrefix(resource, "org_") { spec := internalOrgResourceSpecs[strings.TrimPrefix(resource, "org_")] return spec, spec != nil } spec := internalTenantResourceSpecs[resource] return spec, spec != nil } func usersInternalQuerySpec() *internalQuerySpec { return &internalQuerySpec{ Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted, p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at, (SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`, From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id", DefaultSort: "ORDER BY u.id DESC", DetailKey: "u.id", Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}}, } } func groupsInternalQuerySpec() *internalQuerySpec { return &internalQuerySpec{ Select: `SELECT t.name AS topic,t.state,t.owner AS owner_id,t.seqid AS seq_id,t.delid AS del_id,t.subcnt AS sub_count,t.touchedat AS touched_at,t.createdat AS created_at,t.updatedat AS updated_at,t.public,t.trusted,t.access,t.tags,t.aux, gs.name AS group_name,gs.avatar_file_id,gs.description,gs.group_status,gs.join_approval,gs.mute_all,gs.member_upload_files,gs.member_private_chat,gs.member_invite,gs.max_member_count,gs.max_admin_count,gs.member_count AS business_member_count,gs.version AS settings_version,gs.updated_at AS settings_updated_at`, From: "FROM im_topics AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name", DefaultSort: "ORDER BY t.id DESC", DetailKey: "t.name", Filters: []internalQueryFilter{{"topic", "t.name", "LIKE", "string"}, {"owner_id", "t.owner", "=", "uid"}, {"state", "t.state", "=", "int"}, {"group_status", "gs.group_status", "=", "string"}, {"created_at_start", "t.createdat", ">=", "time"}, {"created_at_end", "t.createdat", "<=", "time"}, {"touched_at_start", "t.touchedat", ">=", "time"}, {"touched_at_end", "t.touchedat", "<=", "time"}}, } } func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string) (string, string, []any, int, int, error) { page := parseIMPositiveInt(req.URL.Query().Get("page"), 1) perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200) where := []string{"1=1"} args := []any{} if spec.ScopeColumn != "" && strings.TrimSpace(scope) != "" { value, err := internalAdminFilterValue("scope", scope, scopeKindForColumn(spec.ScopeColumn)) if err != nil { return "", "", nil, 0, 0, err } where = append(where, spec.ScopeColumn+"=?") args = append(args, value) } for _, filter := range spec.Filters { raw := strings.TrimSpace(req.URL.Query().Get(filter.Param)) if raw == "" { continue } value, err := internalAdminFilterValue(filter.Param, raw, filter.Kind) if err != nil { return "", "", nil, 0, 0, err } op := filter.Op if op == "" { op = "=" } if strings.EqualFold(op, "LIKE") { value = "%" + fmt.Sprint(value) + "%" } where = append(where, filter.Column+" "+op+" ?") args = append(args, value) } whereSQL := strings.Join(where, " AND ") query := spec.Select + " " + spec.From + " WHERE " + whereSQL + " " + spec.DefaultSort countQuery := "SELECT COUNT(*) " + spec.From + " WHERE " + whereSQL return query, countQuery, args, page, perPage, nil } func internalAdminFilterValue(_ string, raw, kind string) (any, error) { switch kind { case "uid": id, ok := parseInternalAdminDBID(raw) if !ok { return nil, types.ErrMalformed } return id, nil case "int": return strconv.ParseInt(raw, 10, 64) default: return raw, nil } } func scopeKindForColumn(column string) string { switch column { case "userid", "fileid": return "uid" default: return "string" } } func parseIMPositiveInt(raw string, fallback int) int { value, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || value <= 0 { return fallback } return value } func normalizeInternalAdminRowsForAPI(rows []map[string]any) []map[string]any { for i := range rows { rows[i] = normalizeInternalAdminRowForAPI(rows[i]) } return rows } func normalizeInternalAdminRowForAPI(row map[string]any) map[string]any { return row } func parseInternalAdminDBID(raw string) (int64, bool) { raw = strings.TrimSpace(raw) if raw == "" { return 0, false } if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 { return id, true } if uid := types.ParseUserId(raw); !uid.IsZero() { return store.DecodeUid(uid), true } if uid := types.ParseUid(raw); !uid.IsZero() { return store.DecodeUid(uid), true } return 0, false } func internalAdminUIDRaw(uid types.Uid) int64 { if uid.IsZero() { return 0 } return store.DecodeUid(uid) } func internalAdminUIDStringRaw(raw string) any { raw = strings.TrimSpace(raw) if raw == "" { return nil } if id, ok := parseInternalAdminDBID(raw); ok { return id } return raw } func numericID(value any) (int64, bool) { switch v := value.(type) { case int64: return v, true case int: return int64(v), true case int32: return int64(v), true case uint64: return int64(v), true case string: id, err := strconv.ParseInt(v, 10, 64) return id, err == nil default: return 0, false } }