610 lines
32 KiB
Go
610 lines
32 KiB
Go
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{
|
|
"private-messages": privateMessagesInternalQuerySpec(),
|
|
"group-messages": groupMessagesInternalQuerySpec(),
|
|
"user-login-logs": {
|
|
Select: `SELECT id,user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
|
|
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
|
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
|
|
},
|
|
"login-failure-logs": {
|
|
Select: `SELECT id,user_id,account_type,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
|
|
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
|
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
|
|
},
|
|
"audit-logs": {
|
|
Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent,
|
|
JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`,
|
|
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
|
|
Filters: []internalQueryFilter{{"actor_user_id", "actor_uid", "=", "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{}
|
|
|
|
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
|
|
}
|
|
if resource == "group-messages" {
|
|
var err error
|
|
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
|
|
if err != nil {
|
|
writeInternalQueryError(wrt, req, err)
|
|
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_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 u.id AS user_id,u.account_no AS display_no,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,
|
|
COALESCE(
|
|
NULLIF(avatar.location,''),
|
|
NULLIF(CASE WHEN p.avatar_file_id REGEXP '^(https?://|data:)' THEN p.avatar_file_id ELSE '' END,''),
|
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar')),''),
|
|
NULLIF(CASE WHEN JSON_TYPE(JSON_EXTRACT(u.public,'$.photo'))='STRING' THEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo')) ELSE '' END,''),
|
|
CASE WHEN JSON_TYPE(JSON_EXTRACT(u.public,'$.photo'))='OBJECT' AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.data')),'') IS NOT NULL
|
|
THEN CONCAT('data:image/',COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.type')),''),'jpeg'),';base64,',JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.photo.data'))) ELSE NULL END,
|
|
NULLIF(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')) REGEXP '^(https?://|data:)' THEN JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.avatar_file_id')) ELSE '' END,''),
|
|
NULLIF((SELECT CASE WHEN om.avatar_file_id REGEXP '^(https?://|data:)' THEN om.avatar_file_id ELSE '' END FROM im_org_members AS om WHERE om.user_id=u.id AND om.avatar_file_id<>'' ORDER BY om.id LIMIT 1),''),
|
|
''
|
|
) AS avatar,
|
|
p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
|
|
CASE WHEN EXISTS(SELECT 1 FROM im_registration_invite_uses AS invite_use WHERE invite_use.user_id=u.id) THEN 'invite_code' WHEN COALESCE(u.phone,'')<>'' THEN 'mobile' ELSE 'admin' END AS register_type,
|
|
(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 LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'",
|
|
DefaultSort: "ORDER BY u.id DESC",
|
|
DetailKey: "u.id",
|
|
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"account_no", "u.account_no", "=", "int"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR))", "LIKE", "string"}, {"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 (SELECT * FROM im_topics WHERE name LIKE 'grp%') 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 privateMessagesInternalQuerySpec() *internalQuerySpec {
|
|
rawContentType := `COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$."x-im-type"')),''),NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type')),''),CASE WHEN JSON_EXTRACT(m.content,'$.callId') IS NOT NULL THEN 'call' ELSE 'text' END)`
|
|
contentType := `CASE ` + rawContentType + ` WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE ` + rawContentType + ` END`
|
|
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
|
|
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.recalled_at IS NOT NULL THEN 'recalled' WHEN m.deletedat IS NOT NULL THEN 'deleted' ELSE 'normal' END`
|
|
return &internalQuerySpec{
|
|
Select: `SELECT m.topic,m.seqid AS seq_id,m.` + "`from`" + ` AS from_user_id,
|
|
COALESCE(su.username,'') AS sender_username,COALESCE(sp.nickname,'') AS sender_nickname,
|
|
rs.userid AS to_user_id,COALESCE(ru.username,'') AS receiver_username,COALESCE(rp.nickname,'') AS receiver_nickname,
|
|
` + contentType + ` AS content_type,` + contentExcerpt + ` AS content_excerpt,
|
|
` + status + ` AS status,m.createdat AS created_at,m.updatedat AS updated_at`,
|
|
From: `FROM (SELECT * FROM im_messages WHERE topic LIKE 'p2p%') AS m
|
|
LEFT JOIN im_users AS su ON su.id=m.` + "`from`" + `
|
|
LEFT JOIN im_user_profiles AS sp ON sp.user_id=m.` + "`from`" + `
|
|
LEFT JOIN im_subscriptions AS rs ON rs.id=(SELECT MIN(s2.id) FROM im_subscriptions AS s2 WHERE s2.topic=m.topic AND s2.userid<>m.` + "`from`" + `)
|
|
LEFT JOIN im_users AS ru ON ru.id=rs.userid
|
|
LEFT JOIN im_user_profiles AS rp ON rp.user_id=rs.userid
|
|
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='private' AND a2.topic=m.topic AND a2.seqid=m.seqid AND a2.action_status='success')`,
|
|
DefaultSort: "ORDER BY m.id DESC",
|
|
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"from_user_id", "m.`from`", "=", "uid"}, {"to_user_id", "rs.userid", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.createdat", ">=", "time"}, {"created_at_end", "m.createdat", "<=", "time"}},
|
|
}
|
|
}
|
|
|
|
func groupMessagesInternalQuerySpec() *internalQuerySpec {
|
|
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
|
|
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
|
|
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
|
|
return &internalQuerySpec{
|
|
Select: `SELECT m.topic,m.seqid AS seq_id,m.from_user AS from_user_id,
|
|
COALESCE(u.username,'') AS sender_username,COALESCE(p.nickname,'') AS sender_nickname,
|
|
COALESCE(gs.name,'') AS group_name,m.topic AS group_no,` + contentType + ` AS content_type,
|
|
` + contentExcerpt + ` AS content_excerpt,
|
|
` + status + ` AS status,COALESCE(m.reply_seqid,0) AS reply_to_seq,m.mentioned_user_ids AS at_users,
|
|
m.created_at,m.updated_at`,
|
|
From: `FROM im_group_messages AS m
|
|
LEFT JOIN im_users AS u ON u.id=m.from_user
|
|
LEFT JOIN im_user_profiles AS p ON p.user_id=m.from_user
|
|
LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci
|
|
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`,
|
|
DefaultSort: "ORDER BY m.id DESC",
|
|
Filters: []internalQueryFilter{
|
|
{"topic", "m.topic", "LIKE", "string"},
|
|
{"seq_id", "m.seqid", "=", "int"},
|
|
{"sender_user_id", "m.from_user", "=", "uid"},
|
|
{"sender_username", "u.username", "LIKE", "string"},
|
|
{"sender_nickname", "p.nickname", "LIKE", "string"},
|
|
{"group_name", "gs.name", "LIKE", "string"},
|
|
{"content_type", contentType, "=", "string"},
|
|
{"status", status, "=", "string"},
|
|
{"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"},
|
|
{"created_at_start", "m.created_at", ">=", "time"},
|
|
{"created_at_end", "m.created_at", "<=", "time"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func groupMessagesInternalQuerySpecForTenant(tenantID types.TenantID) (*internalQuerySpec, error) {
|
|
source, err := internalGroupMessageSource(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
spec := groupMessagesInternalQuerySpec()
|
|
spec.From = strings.Replace(spec.From, "FROM im_group_messages AS m", "FROM "+source+" AS m", 1)
|
|
spec.DefaultSort = "ORDER BY m.created_at DESC,m.id DESC"
|
|
return spec, nil
|
|
}
|
|
|
|
func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
|
|
result, err := store.InternalAdminQueries.Query(tenantID,
|
|
"SELECT table_name FROM im_group_message_shards WHERE status<>'archived' ORDER BY shard_month DESC",
|
|
"SELECT COUNT(*) FROM im_group_message_shards WHERE status<>'archived'", nil, 1, 200)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
selects := make([]string, 0)
|
|
for _, item := range result.Items {
|
|
table := strings.TrimSpace(fmt.Sprint(item["table_name"]))
|
|
if !validInternalGroupMessageTable(table) {
|
|
continue
|
|
}
|
|
selects = append(selects, fmt.Sprintf(`SELECT message_id AS id,topic,seqid,from_user,msg_type,status,content,head,reply_seqid,mentioned_user_ids,created_at,updated_at FROM %s`, table))
|
|
}
|
|
selects = append(selects, `SELECT id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user,
|
|
msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status,
|
|
content,head,reply_seqid,mentioned_user_ids,created_at,updated_at
|
|
FROM im_group_messages AS legacy WHERE NOT EXISTS (
|
|
SELECT 1 FROM im_group_message_topic_shards AS route
|
|
WHERE route.topic=legacy.topic COLLATE utf8mb4_0900_ai_ci AND legacy.seqid BETWEEN route.min_seq AND route.max_seq)`)
|
|
return "(" + strings.Join(selects, " UNION ALL ") + ")", nil
|
|
}
|
|
|
|
func validInternalGroupMessageTable(table string) bool {
|
|
const prefix = "im_group_messages_"
|
|
if len(table) != len(prefix)+6 || !strings.HasPrefix(table, prefix) {
|
|
return false
|
|
}
|
|
for _, ch := range table[len(prefix):] {
|
|
if ch < '0' || ch > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func serveInternalDashboardOverview(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
|
|
groupMessages, err := internalGroupMessageSource(tenantID)
|
|
if err != nil {
|
|
writeInternalQueryError(wrt, req, err)
|
|
return
|
|
}
|
|
query := fmt.Sprintf(`SELECT
|
|
(SELECT COUNT(*) FROM im_users WHERE state<>20) AS total_users,
|
|
(SELECT COUNT(*) FROM im_users WHERE state=0 AND lastseen>=UTC_DATE()) AS active_users,
|
|
(SELECT COUNT(*) FROM im_group_settings WHERE group_status='active') AS total_groups,
|
|
(SELECT COUNT(*) FROM im_topics WHERE state<>20 AND (name LIKE 'p2p%%' OR name LIKE 'grp%%')) AS total_conversations,
|
|
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%%')+(SELECT COUNT(*) FROM %s AS gm)) AS total_messages,
|
|
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%%' AND createdat>=UTC_DATE())+(SELECT COUNT(*) FROM %s AS gm WHERE gm.created_at>=UTC_DATE())) AS today_messages`, groupMessages, groupMessages)
|
|
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, nil)
|
|
if err != nil {
|
|
writeInternalQueryError(wrt, req, err)
|
|
return
|
|
}
|
|
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
|
|
}
|
|
|
|
func serveInternalDashboardTrends(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
|
|
days := parseIMPositiveInt(req.URL.Query().Get("days"), 7)
|
|
if days > 31 {
|
|
days = 31
|
|
}
|
|
groupMessages, err := internalGroupMessageSource(tenantID)
|
|
if err != nil {
|
|
writeInternalQueryError(wrt, req, err)
|
|
return
|
|
}
|
|
query := fmt.Sprintf(`WITH RECURSIVE dates AS (
|
|
SELECT UTC_DATE()-INTERVAL %d DAY AS day
|
|
UNION ALL SELECT day+INTERVAL 1 DAY FROM dates WHERE day<UTC_DATE()
|
|
) SELECT DATE_FORMAT(d.day,'%%Y-%%m-%%d') AS date,
|
|
(SELECT COUNT(*) FROM im_users AS u WHERE u.createdat>=d.day AND u.createdat<d.day+INTERVAL 1 DAY) AS user_count,
|
|
((SELECT COUNT(*) FROM im_messages AS pm WHERE pm.topic LIKE 'p2p%%' AND pm.createdat>=d.day AND pm.createdat<d.day+INTERVAL 1 DAY)+
|
|
(SELECT COUNT(*) FROM %s AS gm WHERE gm.created_at>=d.day AND gm.created_at<d.day+INTERVAL 1 DAY)) AS message_count
|
|
FROM dates AS d ORDER BY d.day ASC`, days-1, groupMessages)
|
|
result, err := store.InternalAdminQueries.Query(tenantID, query, fmt.Sprintf("SELECT %d", days), nil, 1, days)
|
|
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 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
|
|
}
|
|
if filter.Kind == "uid_list" {
|
|
ids := value.([]any)
|
|
placeholders := make([]string, len(ids))
|
|
for i := range ids {
|
|
placeholders[i] = "?"
|
|
}
|
|
where = append(where, filter.Column+" IN ("+strings.Join(placeholders, ",")+")")
|
|
args = append(args, ids...)
|
|
continue
|
|
}
|
|
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)
|
|
case "uid_list":
|
|
parts := strings.Split(raw, ",")
|
|
if len(parts) == 0 || len(parts) > 5000 {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
ids := make([]any, 0, len(parts))
|
|
for _, part := range parts {
|
|
id, ok := parseInternalAdminDBID(strings.TrimSpace(part))
|
|
if !ok {
|
|
return nil, types.ErrMalformed
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
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
|
|
}
|
|
}
|