package main import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) const ( internalCapabilityAdminQuery = "admin_query" internalHeaderService = "X-Service-Name" internalHeaderTimestamp = "X-Timestamp" internalHeaderNonce = "X-Nonce" internalHeaderSignature = "X-Signature" ) type internalAPIConfig struct { Enabled bool `json:"enabled"` TimeWindowSeconds int `json:"time_window_seconds"` Services map[string]internalAPIServiceConfig `json:"services"` } type internalAPIServiceConfig struct { Secret string `json:"secret"` Allow []string `json:"allow"` } type internalAPIRuntime struct { config internalAPIConfig nonces sync.Map } var internalAPI internalAPIRuntime func initInternalAPI(raw json.RawMessage) error { internalAPI = internalAPIRuntime{} if len(raw) == 0 || string(raw) == "null" { return nil } var cfg internalAPIConfig if err := json.Unmarshal(raw, &cfg); err != nil { return err } if cfg.TimeWindowSeconds <= 0 { cfg.TimeWindowSeconds = 300 } if cfg.Services == nil { cfg.Services = map[string]internalAPIServiceConfig{} } internalAPI.config = cfg if cfg.Enabled { logs.Info.Printf("Internal API enabled for %d service(s)", len(cfg.Services)) } return nil } func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix string) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } if !authorizeInternalAPI(wrt, req, internalCapabilityAdminQuery) { return } tail := strings.Trim(strings.TrimPrefix(req.URL.Path, prefix), "/") parts := splitInternalPath(tail) if len(parts) < 3 || parts[0] != "tenants" { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return } tenant, ok := resolveInternalTenant(wrt, req, parts[1]) if !ok { return } switch { case len(parts) == 3 && parts[2] == "users": serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "") case len(parts) == 4 && parts[2] == "users": serveInternalUserQuery(wrt, req, tenant.ID, parts[3]) case len(parts) == 5 && parts[2] == "users" && parts[4] == "devices": serveInternalAdminResourceList(wrt, req, tenant.ID, "user_devices", parts[3]) case len(parts) == 3 && parts[2] == "groups": serveInternalAdminResourceList(wrt, req, tenant.ID, "groups", "") case len(parts) == 4 && parts[2] == "groups": serveInternalGroupQuery(wrt, req, tenant.ID, parts[3]) case len(parts) == 5 && parts[2] == "groups" && parts[4] == "members": serveInternalGroupMembersQuery(wrt, req, tenant.ID, parts[3]) case len(parts) == 5 && parts[2] == "groups" && internalGroupResourceSpecs[parts[4]] != nil: serveInternalAdminResourceList(wrt, req, tenant.ID, "group_"+parts[4], parts[3]) case len(parts) == 5 && parts[2] == "messages": serveInternalMessageQuery(wrt, req, tenant.ID, parts[3], parts[4]) case len(parts) == 3 && parts[2] == "files": serveInternalAdminResourceList(wrt, req, tenant.ID, "files", "") case len(parts) == 4 && parts[2] == "files": serveInternalFileQuery(wrt, req, tenant.ID, parts[3]) case len(parts) == 5 && parts[2] == "files" && parts[4] == "links": serveInternalAdminResourceList(wrt, req, tenant.ID, "file_links", parts[3]) case len(parts) == 3 && internalTenantResourceSpecs[parts[2]] != nil: serveInternalAdminResourceList(wrt, req, tenant.ID, parts[2], "") case len(parts) == 4 && internalTenantResourceSpecs[parts[2]] != nil: serveInternalAdminResourceDetail(wrt, req, tenant.ID, parts[2], parts[3]) case len(parts) == 4 && parts[2] == "reports" && parts[3] == "abuse": writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) case len(parts) == 6 && parts[2] == "reports" && parts[3] == "abuse" && parts[5] == "target": serveInternalAbuseTargetQuery(wrt, req, tenant.ID, parts[4]) case len(parts) == 4 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil: serveInternalAdminResourceList(wrt, req, tenant.ID, "outbox_"+parts[3], "") case len(parts) == 5 && parts[2] == "outbox" && internalOutboxResourceSpecs[parts[3]] != nil: serveInternalAdminResourceDetail(wrt, req, tenant.ID, "outbox_"+parts[3], parts[4]) case len(parts) == 4 && parts[2] == "org" && internalOrgResourceSpecs[parts[3]] != nil: serveInternalAdminResourceList(wrt, req, tenant.ID, "org_"+parts[3], "") case len(parts) == 4 && parts[2] == "workbench" && parts[3] == "apps": serveInternalAdminResourceList(wrt, req, tenant.ID, "workbench/apps", "") case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "summary": serveInternalDashboardSummary(wrt, req, tenant.ID) case len(parts) == 4 && parts[2] == "kafka" && parts[3] == "dead-letters": serveInternalKafkaDeadLettersQuery(wrt, req, tenant.ID) case len(parts) == 5 && parts[2] == "kafka" && parts[3] == "dead-letters": serveInternalKafkaDeadLetterQuery(wrt, req, tenant.ID, parts[4]) default: writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) } } func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool { if !internalAPI.config.Enabled { writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) return false } serviceName := strings.TrimSpace(req.Header.Get(internalHeaderService)) timestampRaw := strings.TrimSpace(req.Header.Get(internalHeaderTimestamp)) nonce := strings.TrimSpace(req.Header.Get(internalHeaderNonce)) signature := strings.TrimSpace(req.Header.Get(internalHeaderSignature)) service, ok := internalAPI.config.Services[serviceName] if !ok || service.Secret == "" || timestampRaw == "" || nonce == "" || signature == "" { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature required", nil) return false } if !internalServiceAllows(service, capability) { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) return false } timestamp, err := strconv.ParseInt(timestampRaw, 10, 64) if err != nil { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal timestamp", nil) return false } window := time.Duration(internalAPI.config.TimeWindowSeconds) * time.Second if delta := time.Since(time.Unix(timestamp, 0)); delta > window || delta < -window { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal signature expired", nil) return false } bodyHash, err := hashInternalRequestBody(req) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return false } signingText := strings.Join([]string{ req.Method, req.URL.RequestURI(), bodyHash, timestampRaw, nonce, }, "\n") if !verifyInternalSignature(service.Secret, signingText, signature) { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "invalid internal signature", nil) return false } if !claimInternalNonce(serviceName, nonce, time.Now().Add(window)) { writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "internal nonce replayed", nil) return false } return true } func splitInternalPath(tail string) []string { if tail == "" { return nil } rawParts := strings.Split(tail, "/") parts := make([]string, 0, len(rawParts)) for _, part := range rawParts { if part == "" { continue } decoded, err := url.PathUnescape(part) if err != nil { decoded = part } parts = append(parts, decoded) } return parts } func resolveInternalTenant(wrt http.ResponseWriter, req *http.Request, tenantCode string) (*types.Tenant, bool) { if tenantCode == "" || store.Tenants == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"}) return nil, false } tenant, err := store.Tenants.GetByCode(tenantCode) if err != nil { if errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return nil, false } writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return nil, false } if !tenant.IsActive() { writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil) return nil, false } return tenant, true } func serveInternalUserQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, userID string) { userDBID, ok := parseInternalAdminDBID(userID) if !ok { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"}) return } if spec, ok := internalAdminQuerySpec("users"); ok { query := spec.Select + " " + spec.From + " WHERE u.id=?" if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{userDBID}); err == nil { writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row)) return } } uid := store.EncodeUid(userDBID) user, err := store.Users.Get(tenantID, uid) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "user_id": userDBID, "state": user.State, "state_at": user.StateAt, "username": user.Username, "phone": user.Phone, "public": user.Public, "trusted": user.Trusted, "last_seen": user.LastSeen, "user_agent": user.UserAgent, "tags": user.Tags, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, }) } func serveInternalGroupQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) { if topic == "" || store.GroupBusiness == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } if spec, ok := internalAdminQuerySpec("groups"); ok { query := spec.Select + " " + spec.From + " WHERE t.name=?" if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{topic}); err == nil { writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row)) return } } settings, err := store.GroupBusiness.GetSettings(tenantID, topic) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, internalGroupSettingsPayload(settings)) } func serveInternalGroupMembersQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) { if topic == "" || store.GroupBusiness == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } spec := &internalQuerySpec{ Select: `SELECT gm.id,gm.topic,gm.user_id,u.state,u.public,p.nickname,p.public_account,s.modewant AS mode_want,s.modegiven AS mode_given,s.recvseqid AS recv_seq_id,s.readseqid AS read_seq_id,s.delid AS del_id, gm.role,gm.member_status,gm.nickname AS group_nickname,gm.pinned,gm.mute_notifications,gm.join_source,gm.joined_by,gm.joined_at,gm.left_at,gm.removed_by,gm.version,gm.created_at,gm.updated_at`, From: "FROM im_group_members AS gm LEFT JOIN im_users AS u ON u.id=gm.user_id LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id LEFT JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id", DefaultSort: "ORDER BY gm.id DESC", ScopeColumn: "gm.topic", Filters: []internalQueryFilter{ {"user_id", "gm.user_id", "=", "uid"}, {"role", "gm.role", "=", "string"}, {"member_status", "gm.member_status", "=", "string"}, {"q", "gm.nickname", "LIKE", "string"}, {"eligible_role", "gm.role", "=", "string"}, }, } query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, topic) if err == nil { if result, queryErr := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage); queryErr == nil { writeIMSuccess(wrt, req, map[string]any{ "items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage, "next_cursor": "", "has_more": false, }) return } } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 200) members, nextCursor, err := store.GroupBusiness.ListMembers(tenantID, topic, types.GroupMemberListOptions{ Role: strings.TrimSpace(req.URL.Query().Get("role")), EligibleRole: strings.TrimSpace(req.URL.Query().Get("eligible_role")), Query: strings.TrimSpace(req.URL.Query().Get("q")), Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), Limit: limit, }) if err != nil { writeInternalQueryError(wrt, req, err) return } items := make([]any, 0, len(members)) for _, member := range members { items = append(items, map[string]any{ "user_id": internalAdminUIDRaw(member.UserID), "role": member.Role, "member_status": member.MemberStatus, "nickname": member.Nickname, "pinned": member.Pinned, "mute_notifications": member.MuteNotifications, "join_source": member.JoinSource, "joined_by": internalAdminUIDRaw(member.JoinedBy), "joined_at": member.JoinedAt, "left_at": member.LeftAt, "removed_by": internalAdminUIDRaw(member.RemovedBy), "version": member.Version, "created_at": member.CreatedAt, "updated_at": member.UpdatedAt, }) } writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) } func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic, seqRaw string) { seq, err := strconv.ParseInt(seqRaw, 10, 64) if topic == "" || err != nil || seq <= 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } msg, err := store.Messages.GetOne(tenantID, topic, seq) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "topic": msg.Topic, "seq_id": msg.SeqId, "from": internalAdminUIDStringRaw(msg.From), "client_msg_id": msg.ClientMsgID, "version": msg.Version, "head": msg.Head, "content": msg.Content, "deleted_at": msg.DeletedAt, "recalled_at": msg.RecalledAt, "recalled_by": internalAdminUIDStringRaw(msg.RecalledBy), "created_at": msg.CreatedAt, "updated_at": msg.UpdatedAt, }) } func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) { if fileID == "" || store.Files == nil { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "file_id"}) return } file, err := store.Files.Get(tenantID, fileID) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, map[string]any{ "file_id": file.Id, "status": file.Status, "user_id": internalAdminUIDStringRaw(file.User), "mime_type": file.MimeType, "size": file.Size, "location": file.Location, "etag": file.ETag, "created_at": file.CreatedAt, "updated_at": file.UpdatedAt, }) } func serveInternalKafkaDeadLettersQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) { if store.HTTPInfrastructure == nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 200) page, err := store.HTTPInfrastructure.ListKafkaDeadLetters(tenantID, types.KafkaDeadLetterListOptions{ Status: strings.TrimSpace(req.URL.Query().Get("status")), BizType: strings.TrimSpace(req.URL.Query().Get("biz_type")), BizID: strings.TrimSpace(req.URL.Query().Get("biz_id")), EventType: strings.TrimSpace(req.URL.Query().Get("event_type")), Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")), Limit: limit, }) if err != nil { writeInternalQueryError(wrt, req, err) return } items := make([]any, 0, len(page.Items)) for _, dead := range page.Items { items = append(items, internalKafkaDeadLetterPayload(&dead, false)) } writeIMSuccess(wrt, req, map[string]any{ "items": items, "next_cursor": page.NextCursor, "has_more": page.NextCursor != "", }) } func serveInternalKafkaDeadLetterQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, deadID string) { if store.HTTPInfrastructure == nil || strings.TrimSpace(deadID) == "" { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "dead_id"}) return } dead, err := store.HTTPInfrastructure.GetKafkaDeadLetter(tenantID, deadID) if err != nil { writeInternalQueryError(wrt, req, err) return } writeIMSuccess(wrt, req, internalKafkaDeadLetterPayload(dead, true)) } func internalGroupSettingsPayload(settings *types.GroupSettings) map[string]any { if settings == nil { return nil } return map[string]any{ "topic": settings.Topic, "owner_user_id": internalAdminUIDRaw(settings.OwnerUserID), "name": settings.Name, "avatar_file_id": settings.AvatarFileID, "description": settings.Description, "group_status": settings.GroupStatus, "join_approval": settings.JoinApproval, "mute_all": settings.MuteAll, "member_upload_files": settings.MemberUploadFiles, "member_private_chat": settings.MemberPrivateChat, "member_invite": settings.MemberInvite, "max_member_count": settings.MaxMemberCount, "max_admin_count": settings.MaxAdminCount, "member_count": settings.MemberCount, "version": settings.Version, "created_by": internalAdminUIDRaw(settings.CreatedBy), "dissolved_by": internalAdminUIDRaw(settings.DissolvedBy), "dissolved_at": settings.DissolvedAt, "created_at": settings.CreatedAt, "updated_at": settings.UpdatedAt, } } func internalKafkaDeadLetterPayload(dead *types.KafkaDeadLetter, includeEnvelope bool) map[string]any { if dead == nil { return nil } payload := map[string]any{ "id": dead.ID, "dead_id": dead.DeadID, "event_id": dead.EventID, "parent_event_id": dead.ParentEventID, "source_topic": dead.SourceTopic, "consumer_group": dead.ConsumerGroup, "event_type": dead.EventType, "biz_type": dead.BizType, "biz_id": dead.BizID, "retry_count": dead.RetryCount, "last_reason": dead.LastReason, "last_error": dead.LastError, "status": dead.Status, "replay_count": dead.ReplayCount, "created_at": dead.CreatedAt, "updated_at": dead.UpdatedAt, } if includeEnvelope { var envelope any if len(dead.Envelope) > 0 && json.Unmarshal(dead.Envelope, &envelope) == nil { payload["envelope"] = envelope } else { payload["envelope_raw"] = string(dead.Envelope) } } return payload } func writeInternalQueryError(wrt http.ResponseWriter, req *http.Request, err error) { switch { case errors.Is(err, types.ErrUserNotFound), errors.Is(err, types.ErrTopicNotFound), errors.Is(err, types.ErrNotFound): writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil) case errors.Is(err, types.ErrMalformed): writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) case errors.Is(err, types.ErrPermissionDenied): writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil) default: writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) } } func parseInternalUid(raw string) types.Uid { if uid := types.ParseUserId(raw); !uid.IsZero() { return uid } id, ok := parseInternalAdminDBID(raw) if !ok { return types.ZeroUid } return store.EncodeUid(id) } func internalServiceAllows(service internalAPIServiceConfig, capability string) bool { for _, allowed := range service.Allow { allowed = strings.TrimSpace(allowed) if allowed == "*" || allowed == capability { return true } } return false } func hashInternalRequestBody(req *http.Request) (string, error) { if req.Body == nil { sum := sha256.Sum256(nil) return hex.EncodeToString(sum[:]), nil } body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20)) if err != nil { return "", err } if err := req.Body.Close(); err != nil { return "", err } req.Body = io.NopCloser(bytes.NewReader(body)) sum := sha256.Sum256(body) return hex.EncodeToString(sum[:]), nil } func verifyInternalSignature(secret, signingText, signature string) bool { signature = strings.TrimPrefix(signature, "sha256=") mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(signingText)) expected := mac.Sum(nil) if decoded, err := hex.DecodeString(signature); err == nil { return hmac.Equal(decoded, expected) } if decoded, err := base64.StdEncoding.DecodeString(signature); err == nil { return hmac.Equal(decoded, expected) } return false } func claimInternalNonce(serviceName, nonce string, expiresAt time.Time) bool { ttl := time.Until(expiresAt) if ok, enabled, err := store.ClaimShortState("internal_api_nonce", fmt.Sprintf("%s:%s", serviceName, nonce), ttl); enabled { if err != nil { logs.Warn.Println("internal api: failed to claim nonce in redis", err) return false } return ok } now := time.Now() internalAPI.nonces.Range(func(key, value any) bool { if expires, ok := value.(time.Time); ok && expires.Before(now) { internalAPI.nonces.Delete(key) } return true }) nonceKey := fmt.Sprintf("%s:%s", serviceName, nonce) _, loaded := internalAPI.nonces.LoadOrStore(nonceKey, expiresAt) return !loaded }