feat: complete IM P0 and P1 reliability work

This commit is contained in:
richjohnrambo
2026-08-17 13:43:27 +08:00
parent ddf0f7a55e
commit f2c229135b
43 changed files with 4512 additions and 1181 deletions
+21 -1
View File
@@ -213,6 +213,7 @@ type Adapter interface {
// points fail closed when the configured adapter does not support tenant resolution.
type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error)
TenantGetByID(id t.TenantID) (*t.Tenant, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
@@ -221,6 +222,17 @@ type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
}
// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used
// by product HTTP middleware and online business-event delivery.
type TenantHTTPInfrastructureAdapter interface {
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error
TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error)
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
@@ -304,13 +316,21 @@ type TenantBusinessAdapter interface {
TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error)
TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error)
TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, time.Time) (*t.GroupJoinRequest, error)
TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error)
TenantGroupAnnouncementLatest(t.TenantID, string) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error
TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error)
TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error)
TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error
TenantGroupCreate(t.TenantID, *t.Topic, t.Uid, any, *t.GroupSettings, []t.Uid, any, time.Time) error
TenantGroupInviteMember(t.TenantID, string, t.Uid, t.Uid, string, string, any, time.Time) (bool, error)
TenantGroupLeave(t.TenantID, string, t.Uid, time.Time) error
TenantGroupRoleUpdate(t.TenantID, string, t.Uid, t.Uid, string, t.AccessMode, time.Time) error
TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error
TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error)
TenantGroupEventListPending(t.TenantID, int, time.Time) ([]t.GroupEvent, error)
TenantGroupEventMarkDelivered(t.TenantID, string, time.Time) error
TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error)
TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error)
+210 -179
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
//go:build mysql
// +build mysql
package mysql
// ensureIMGroupBusinessTables installs the product group-chat schema in the
// currently selected tenant database. Tenant databases intentionally do not
// carry tenant_id columns.
func ensureIMGroupBusinessTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_settings (
topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
description VARCHAR(512) NOT NULL DEFAULT '', group_status VARCHAR(16) NOT NULL DEFAULT 'active',
join_approval TINYINT NOT NULL DEFAULT 0, mute_all TINYINT NOT NULL DEFAULT 0,
member_upload_files TINYINT NOT NULL DEFAULT 1, member_private_chat TINYINT NOT NULL DEFAULT 1,
member_invite TINYINT NOT NULL DEFAULT 1, max_member_count INT NOT NULL DEFAULT 0,
max_admin_count INT NOT NULL DEFAULT 10, member_count INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1, created_by BIGINT NOT NULL, dissolved_by BIGINT,
dissolved_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic), INDEX idx_im_group_settings_owner(owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active',
nickname VARCHAR(64) NOT NULL DEFAULT '', pinned TINYINT NOT NULL DEFAULT 0,
mute_notifications TINYINT NOT NULL DEFAULT 0, join_source VARCHAR(32) NOT NULL DEFAULT 'manual',
joined_by BIGINT, joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), left_at DATETIME(3),
removed_by BIGINT, version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_members_topic_user(topic, user_id),
INDEX idx_im_group_members_user_status(user_id, member_status, updated_at),
INDEX idx_im_group_members_topic_role(topic, role, member_status, id),
INDEX idx_im_group_members_topic_status(topic, member_status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL, target_user_id BIGINT NOT NULL, message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', result VARCHAR(32) NOT NULL DEFAULT '',
join_request_id VARCHAR(64) NOT NULL DEFAULT '', idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL, handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_invitations_invitation(invitation_id),
UNIQUE INDEX uk_im_group_invitations_idempotency(inviter_user_id, idempotency_key),
INDEX idx_im_group_invitations_target(target_user_id, status, expires_at),
INDEX idx_im_group_invitations_topic_status(topic, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
applicant_user_id BIGINT NOT NULL, inviter_user_id BIGINT, source VARCHAR(32) NOT NULL DEFAULT 'manual',
qr_token_hash CHAR(64) NOT NULL DEFAULT '', message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', reject_reason VARCHAR(256) NOT NULL DEFAULT '',
handled_by BIGINT, handled_at DATETIME(3), expires_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_join_requests_request(request_id),
UNIQUE INDEX uk_im_group_join_requests_idempotency(applicant_user_id, idempotency_key),
INDEX idx_im_group_join_requests_topic_status(topic, status, id),
INDEX idx_im_group_join_requests_applicant(applicant_user_id, topic, status, id),
INDEX idx_im_group_join_requests_expire(status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
author_user_id BIGINT NOT NULL, content TEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'current',
version INT NOT NULL DEFAULT 1, published_at DATETIME(3) NOT NULL, deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcements_announcement(announcement_id),
UNIQUE INDEX uk_im_group_announcements_idempotency(topic, author_user_id, idempotency_key),
INDEX idx_im_group_announcements_topic_status(topic, status, version DESC, id DESC),
INDEX idx_im_group_announcements_topic_time(topic, published_at DESC, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(announcement_id, user_id), INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_mutes (
topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
muted_at DATETIME(3) NOT NULL, expires_at DATETIME(3), reason VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active', revoked_by BIGINT, revoked_at DATETIME(3),
version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic, user_id), INDEX idx_im_group_mutes_topic_status(topic, status, expires_at),
INDEX idx_im_group_mutes_user_status(user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL, created_by BIGINT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL, revoked_by BIGINT, revoked_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_qrcodes_token(token_id),
UNIQUE INDEX uk_im_group_qrcodes_hash(token_hash), INDEX idx_im_group_qrcodes_topic_status(topic, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq INT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_files_group_file(group_file_id),
UNIQUE INDEX uk_im_group_files_topic_file(topic, file_id),
UNIQUE INDEX uk_im_group_files_idempotency(topic, uploader_id, idempotency_key),
INDEX idx_im_group_files_topic_type(topic, status, file_type, id DESC),
INDEX idx_im_group_files_topic_name(topic, status, file_name), INDEX idx_im_group_files_uploader(uploader_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
operator_id BIGINT NOT NULL, target_user_id BIGINT, action VARCHAR(64) NOT NULL,
object_type VARCHAR(32) NOT NULL DEFAULT '', object_id VARCHAR(64) NOT NULL DEFAULT '',
before_data JSON, after_data JSON, request_id VARCHAR(128) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_admin_logs_log(log_id),
INDEX idx_im_group_admin_logs_topic_time(topic, id DESC),
INDEX idx_im_group_admin_logs_operator(operator_id, id DESC), INDEX idx_im_group_admin_logs_object(object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0, result_body JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_idempotency_request(user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created(created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL, payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_events_outbox_event(event_id),
INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
//go:build mysql
package mysql
import (
"database/sql"
"strings"
"testing"
)
type recordingGroupSchemaExecutor struct {
statements []string
}
func (e *recordingGroupSchemaExecutor) Exec(query string, _ ...any) (sql.Result, error) {
e.statements = append(e.statements, query)
return nil, nil
}
func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 12 {
t.Fatalf("created %d group tables, want 12", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
t.Fatalf("group schema must use the selected tenant database: %s", statement)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//go:build mysql
// +build mysql
package mysql
func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL, route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing', response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB, expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_http_idempotency_scope(user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_business_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL,
target_type VARCHAR(16) NOT NULL, target VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL,
payload JSON, status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_business_events_outbox_event(event_id),
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target(target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,27 @@
//go:build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 2 {
t.Fatalf("created %d infrastructure tables, want 2", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_http_idempotency", "im_business_events_outbox"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
# - im_tenant_config
#
# - Default tenant business database: tinode
# - users, topics, subscriptions, messages and extension tables
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
# - im_http_idempotency and im_business_events_outbox for reliable HTTP writes
# - business tables do not store tenant_id in independent-database mode
#
# The previous shared-table tenant_id draft has been removed from this file to
File diff suppressed because it is too large Load Diff
+514 -26
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
@@ -130,11 +132,16 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
where += " AND version=?"
args = append(args, patch.Version)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_settings SET "+strings.Join(set, ",")+" WHERE "+where, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_settings SET "+strings.Join(set, ",")+" WHERE "+where, args...)
if err != nil {
return nil, err
}
@@ -144,6 +151,12 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
}
return nil, t.ErrNotFound
}
if err = insertGroupEventTx(tx, topic, t.ZeroUid, "group.settings.updated", map[string]any{"topic": topic}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupSettingsByTopic(ctx, topic)
}
@@ -310,11 +323,16 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
where += " AND version=?"
args = append(args, patch.Version)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...)
if err != nil {
return nil, err
}
@@ -324,7 +342,15 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
}
return nil, t.ErrNotFound
}
_ = a.recountGroupMembers(ctx, topic)
if err = recountGroupMembersTx(tx, topic); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, topic, uid, "group.member.updated", map[string]any{"user_id": uid.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid))
}
@@ -379,16 +405,29 @@ func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRe
if req.IdempotencyKey == "" {
req.IdempotencyKey = req.RequestID
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
req.RequestID, req.Topic, decodeUID(req.ApplicantUserID), nullableUID(req.InviterUserID), req.Source, req.QRTokenHash, req.Message, req.Status, req.RejectReason, req.ExpiresAt, req.IdempotencyKey, req.Version, now, now)
if err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, req.Topic, req.ApplicantUserID, "group.join_request.created", map[string]any{
"request_id": req.RequestID, "applicant_user_id": req.ApplicantUserID.UserId(),
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE request_id=?", req.RequestID)
}
@@ -439,36 +478,72 @@ func (a *adapter) TenantGroupJoinRequestList(id t.TenantID, topic, status, curso
return items, next, rows.Err()
}
func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID string, actor t.Uid, action, rejectReason string, version int, now time.Time) (*t.GroupJoinRequest, error) {
func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID string, actor t.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*t.GroupJoinRequest, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupJoinRequestResolve(id, topic, requestID, actor, action, rejectReason, version, now)
return tenantAdp.TenantGroupJoinRequestResolve(id, topic, requestID, actor, action, rejectReason, version, memberPrivate, now)
}
status := t.GroupJoinRequestStatusRejected
if action == "accept" {
status = t.GroupJoinRequestStatusAccepted
}
args := []any{status, rejectReason, decodeUID(actor), now, now, topic, requestID, t.GroupJoinRequestStatusPending}
where := "topic=? AND request_id=? AND status=?"
if version > 0 {
where += " AND version=?"
args = append(args, version)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 WHERE "+where, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
if version > 0 {
return nil, t.ErrInvalidResponse
}
defer tx.Rollback()
var current t.GroupJoinRequest
err = tx.QueryRowxContext(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=? FOR UPDATE", topic, requestID).StructScan(&current)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
scanGroupJoinRequest(&current)
if current.Status != t.GroupJoinRequestStatusPending {
return nil, t.ErrInvalidResponse
}
if version > 0 && current.Version != version {
return nil, t.ErrInvalidResponse
}
if action == "accept" {
sub := &t.Subscription{TenantID: id, User: current.ApplicantUserID.String(), Topic: topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
return nil, err
}
member := &t.GroupMember{Topic: topic, UserID: current.ApplicantUserID, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: actor, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return nil, err
}
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1
WHERE topic=? AND request_id=? AND status=?`, status, rejectReason, decodeUID(actor), now, now, topic, requestID, t.GroupJoinRequestStatusPending); err != nil {
return nil, err
}
if err = recountGroupMembersTx(tx, topic); err != nil {
return nil, err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, topic, actor, "group.join_request."+status, map[string]any{
"request_id": requestID, "applicant_user_id": current.ApplicantUserID.UserId(), "status": status,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=?", topic, requestID)
}
@@ -530,6 +605,11 @@ func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnounce
VALUES(?,?,?,?,?,?,?,?,?,?)`, ann.AnnouncementID, ann.Topic, decodeUID(ann.AuthorUserID), ann.Content, t.GroupAnnouncementStatusCurrent, version, now, ann.IdempotencyKey, now, now); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, ann.Topic, ann.AuthorUserID, "group.announcement.updated", map[string]any{
"announcement_id": ann.AnnouncementID, "version": version,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
@@ -607,17 +687,30 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
if mute.Status == "" {
mute.Status = t.GroupMuteStatusActive
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
VALUES(?,?,?,?,?,?,?,1)
ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`,
mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status)
if err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, mute.Topic, mute.MutedBy, "group.mute.updated", map[string]any{
"user_id": mute.UserID.UserId(), "muted": true, "expires_at": mute.ExpiresAt,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupMuteByQuery(ctx, "SELECT * FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID))
}
@@ -627,11 +720,16 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
} else if tenantAdp != a {
return tenantAdp.TenantGroupMuteDelete(id, topic, uid, actor, now)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND status=?",
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND status=?",
t.GroupMuteStatusRevoked, decodeUID(actor), now, now, topic, decodeUID(uid), t.GroupMuteStatusActive)
if err != nil {
return err
@@ -639,7 +737,397 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
if err = insertGroupEventTx(tx, topic, actor, "group.mute.updated", map[string]any{
"user_id": uid.UserId(), "muted": false,
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any,
settings *t.GroupSettings, members []t.Uid, memberPrivate any, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupCreate(id, topic, owner, ownerPrivate, settings, members, memberPrivate, now)
}
if topic == nil || settings == nil || topic.Id == "" || settings.Topic != topic.Id || owner.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = a.topicCreate(tx, topic); err != nil {
return err
}
ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(),
Topic: topic.Id, ModeGiven: t.ModeCFull, ModeWant: topic.GetAccess(owner), Private: ownerPrivate}
ownerSub.InitTimes()
if err = createSubscription(tx, ownerSub, false); err != nil {
return err
}
settings.MemberCount = 1
if err = insertGroupSettingsTx(tx, settings); err != nil {
return err
}
ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, ownerMember); err != nil {
return err
}
seen := map[t.Uid]bool{owner: true}
for _, uid := range members {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: topic.Id,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
return err
}
member := &t.GroupMember{Topic: topic.Id, UserID: uid, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return err
}
}
if err = recountGroupMembersTx(tx, topic.Id); err != nil {
return err
}
if err = updateGroupTopicSubCountTx(tx, topic.Id); err != nil {
return err
}
if err = insertGroupEventTx(tx, topic.Id, owner, "group.created", map[string]any{
"owner_user_id": owner.UserId(), "member_count": len(seen),
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter, target t.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupInviteMember(id, topic, inviter, target, message, idempotencyKey, private, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var deletedAt sql.NullTime
err = tx.QueryRowContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=? FOR UPDATE", topic, decodeUID(target)).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return false, err
}
if err == nil && !deletedAt.Valid {
return false, nil
}
sub := &t.Subscription{TenantID: id, User: target.String(), Topic: topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: private}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
return false, err
}
invitationID := "ginv_" + store.Store.GetUidString()
if idempotencyKey == "" {
idempotencyKey = invitationID
} else {
idempotencyKey += ":" + target.UserId()
}
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations
(invitation_id,topic,inviter_user_id,target_user_id,message,status,result,idempotency_key,expires_at,handled_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, invitationID, topic, decodeUID(inviter), decodeUID(target), message,
t.GroupInvitationStatusJoined, "joined", idempotencyKey, now.Add(7*24*time.Hour), now, now, now); err != nil {
return false, err
}
member := &t.GroupMember{Topic: topic, UserID: target, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inviter, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return false, err
}
if err = recountGroupMembersTx(tx, topic); err != nil {
return false, err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
return false, err
}
if err = insertGroupEventTx(tx, topic, inviter, "group.member.joined", map[string]any{
"user_id": target.UserId(), "source": "invitation",
}); err != nil {
return false, err
}
if err = tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func (a *adapter) TenantGroupLeave(id t.TenantID, topic string, uid t.Uid, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupLeave(id, topic, uid, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decodeUID(uid))
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=?
WHERE topic=? AND user_id=? AND member_status=?`, t.GroupMemberStatusLeft, now, now, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil {
return err
}
if err = recountGroupMembersTx(tx, topic); err != nil {
return err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
return err
}
if err = insertGroupEventTx(tx, topic, uid, "group.member.left", map[string]any{"user_id": uid.UserId()}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, target t.Uid,
role string, modeGiven t.AccessMode, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupRoleUpdate(id, topic, actor, target, role, modeGiven, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), now, topic, decodeUID(target))
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
res, err = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
role, now, topic, decodeUID(target), t.GroupMemberStatusActive)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
if err = insertGroupEventTx(tx, topic, actor, "group.role.updated", map[string]any{
"user_id": target.UserId(), "role": role,
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid,
version int, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupOwnerTransfer(id, topic, previousOwner, newOwner, version, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
updates := []struct {
uid t.Uid
mode t.AccessMode
role string
}{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), now, topic, decodeUID(update.uid))
if execErr != nil {
return execErr
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
res, execErr = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
update.role, now, topic, decodeUID(update.uid), t.GroupMemberStatusActive)
if execErr != nil {
return execErr
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
}
if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET owner=?,updatedat=? WHERE name=?", decodeUID(newOwner), now, topic); err != nil {
return err
}
args := []any{decodeUID(newOwner), now, topic}
query := "UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?"
if version > 0 {
query += " AND version=?"
args = append(args, version)
}
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrInvalidResponse
}
if err = insertGroupEventTx(tx, topic, previousOwner, "group.owner.transferred", map[string]any{
"previous_owner": previousOwner.UserId(), "owner": newOwner.UserId(),
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, admin bool, now time.Time) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now)
}
if admin {
return true, nil
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var muteAll, memberMuted bool
err := a.db.QueryRowContext(ctx, `SELECT
COALESCE((SELECT mute_all FROM im_group_settings WHERE topic=? AND group_status=? LIMIT 1),0),
EXISTS(SELECT 1 FROM im_group_mutes WHERE topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`,
topic, t.GroupStatusActive, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted)
if err != nil {
return false, err
}
return !muteAll && !memberMuted, nil
}
func (a *adapter) TenantGroupEventListPending(id t.TenantID, limit int, now time.Time) ([]t.GroupEvent, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupEventListPending(id, limit, now)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_group_events_outbox
WHERE status='pending' AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]t.GroupEvent, 0, limit)
for rows.Next() {
var event t.GroupEvent
if err = rows.StructScan(&event); err != nil {
return nil, err
}
event.ActorUserID = store.EncodeUid(event.ActorRaw)
events = append(events, event)
}
return events, rows.Err()
}
func (a *adapter) TenantGroupEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupEventMarkDelivered(id, eventID, now)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `UPDATE im_group_events_outbox SET status='delivered',attempts=attempts+1,
delivered_at=?,updated_at=? WHERE event_id=? AND status='pending'`, now, now, eventID)
return err
}
func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error {
_, err := tx.Exec(`INSERT INTO im_group_settings
(topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name,
settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll,
settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount,
settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
return err
}
func upsertGroupMemberTx(tx *sqlx.Tx, member *t.GroupMember) error {
_, err := tx.Exec(`INSERT INTO im_group_members
(topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname),
pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source),
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned,
member.MuteNotifications, member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version,
member.CreatedAt, member.UpdatedAt)
return err
}
func recountGroupMembersTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic)
return err
}
func updateGroupTopicSubCountTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions
WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?`, topic, t.GrpToChn(topic), topic)
return err
}
func insertGroupEventTx(tx *sqlx.Tx, topic string, actor t.Uid, eventType string, payload any) error {
_, err := tx.Exec(`INSERT INTO im_group_events_outbox(event_id,topic,actor_user_id,event_type,payload,status)
VALUES(?,?,?,?,?,'pending')`, "gev_"+store.Store.GetUidString(), topic, decodeUID(actor), eventType, common.ToJSON(payload))
return err
}
func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) {
+253
View File
@@ -0,0 +1,253 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) {
if !a.tenantScopedDB {
return a.routedTenantAdapter(id)
}
if err := validTenant(id); err != nil {
return nil, err
}
return a, nil
}
func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, false, err
}
if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" {
return nil, false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyBegin(id, record)
}
now := t.TimeNow()
if record.ExpiresAt.IsZero() {
record.ExpiresAt = now.Add(24 * time.Hour)
}
_, err = a.db.Exec(`INSERT INTO im_http_idempotency
(user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, decodeUID(record.UserID), record.Method, record.Route, record.Key,
record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now)
if err == nil {
record.State = t.HTTPIdempotencyProcessing
record.CreatedAt = now
record.UpdatedAt = now
return record, true, nil
}
if !isDupe(err) {
return nil, false, err
}
existing, err := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
if err == nil && !existing.ExpiresAt.After(now) {
res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, now)
if deleteErr != nil {
return nil, false, deleteErr
}
if deleted, _ := res.RowsAffected(); deleted > 0 {
return a.TenantHTTPIdempotencyBegin(id, record)
}
current, getErr := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
return current, false, getErr
}
return existing, false, err
}
func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyComplete(id, record)
}
now := t.TimeNow()
res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=?
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, decodeUID(record.UserID),
record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyAbort(id, record)
}
_, err = a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
return err
}
func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) {
row := struct {
UserID int64 `db:"user_id"`
Method string
Route string
Key string `db:"idempotency_key"`
BodyHash string `db:"body_hash"`
State string
ResponseStatus int `db:"response_status"`
ResponseBody []byte `db:"response_body"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ExpiresAt time.Time `db:"expires_at"`
}{}
err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at
FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=?`, decodeUID(uid), method, route, key)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route,
Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus,
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil
}
func (a *adapter) TenantBusinessEventCreate(id t.TenantID, event *t.BusinessEvent) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if event == nil || event.EventID == "" || event.Target == "" || event.EventType == "" ||
(event.TargetType != "user" && event.TargetType != "topic") {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventCreate(id, event)
}
now := t.TimeNow()
_, err = a.db.Exec(`INSERT INTO im_business_events_outbox
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, event.EventID,
event.TargetType, event.Target, decodeUID(event.ActorUserID), event.EventType, event.Payload,
t.BusinessEventPending, now, now)
return err
}
func (a *adapter) TenantBusinessEventListPending(id t.TenantID, limit int, now time.Time) ([]t.BusinessEvent, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventListPending(id, limit, now)
}
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := a.db.Queryx(`SELECT * FROM im_business_events_outbox
WHERE status=? AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, t.BusinessEventPending, now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]t.BusinessEvent, 0, limit)
for rows.Next() {
var row struct {
ID int64
EventID string `db:"event_id"`
TargetType string `db:"target_type"`
Target string
ActorUserID int64 `db:"actor_user_id"`
EventType string `db:"event_type"`
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
events = append(events, t.BusinessEvent{ID: row.ID, EventID: row.EventID, TargetType: row.TargetType,
Target: row.Target, ActorUserID: store.EncodeUid(row.ActorUserID), EventType: row.EventType,
Payload: row.Payload, Status: row.Status, Attempts: row.Attempts, NextRetryAt: row.NextRetryAt,
DeliveredAt: row.DeliveredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
}
return events, rows.Err()
}
func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventMarkDelivered(id, eventID, now)
}
res, err := a.db.Exec(`UPDATE im_business_events_outbox SET status=?,delivered_at=?,attempts=attempts+1,updated_at=?
WHERE event_id=? AND status=?`, t.BusinessEventDelivered, now, now, eventID, t.BusinessEventPending)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_business_events_outbox
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventID, targetType,
target, decodeUID(actor), eventType, encoded, t.BusinessEventPending, now, now)
return err
}
func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error {
encoded, err := json.Marshal(action)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_system_notifications
(user_id,event_id,event_type,title,summary,action,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title),
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, decodeUID(uid), eventID,
eventType, title, summary, encoded, now, now)
return err
}
+165 -66
View File
@@ -15,6 +15,7 @@ import (
"log"
"os"
"reflect"
"strings"
"testing"
"time"
@@ -48,17 +49,126 @@ var testData *test_data.TestData
var dummyUid1 = types.Uid(12345)
var dummyUid2 = types.Uid(54321)
func TestCreateDb(t *testing.T) {
if err := adp.CreateDb(config.Reset); err != nil {
t.Fatal(err)
}
// Saved db is closed, get a fresh one.
db = adp.GetTestDB().(*sqlx.DB)
}
func TestGroupBusinessTablesCreated(t *testing.T) {
tables := []string{
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
"im_group_files", "im_group_admin_logs", "im_group_idempotency", "im_group_events_outbox",
}
args := make([]any, len(tables))
for i, table := range tables {
args[i] = table
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",")
var count int
query := "SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("group business table count: got %d, want %d", count, len(tables))
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("tenant business tables contain %d tenant_id columns", count)
}
}
func TestHTTPInfrastructureTablesCreated(t *testing.T) {
tables := []string{"im_http_idempotency", "im_business_events_outbox"}
args := make([]any, len(tables))
for i, table := range tables {
args[i] = table
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",")
var count int
query := "SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("HTTP infrastructure table count: got %d, want %d", count, len(tables))
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("HTTP infrastructure tables contain %d tenant_id columns", count)
}
}
func TestTenantAuthIsolation(t *testing.T) {
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantAuthAdapter")
}
var routingConfig struct {
Enabled bool `json:"tenant_db_routing"`
}
if err := json.Unmarshal(config.Adapters[adp.GetName()], &routingConfig); err != nil {
t.Fatal(err)
}
if !routingConfig.Enabled {
t.Skip("tenant_db_routing is disabled in the MySQL test configuration")
}
router, ok := adp.(adapter.TenantRoutedAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantRoutedAdapter")
}
now := types.TimeNow()
suffix := fmt.Sprintf("%d", now.UnixNano())
createTenant := func(code string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,state,created_by,updated_by) VALUES(?,?,?,?,?)",
code+suffix, code, types.TenantStateActive, 0, 0)
dbNames := []string{"im_tenant_auth_a_" + suffix, "im_tenant_auth_b_" + suffix}
quoteIdent := func(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
var baseDB string
if err := db.Get(&baseDB, "SELECT DATABASE()"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM im_tenant WHERE code IN (?,?)", "test-im_auth-a-"+suffix, "test-im_auth-b-"+suffix)
for _, dbName := range dbNames {
db.Exec("DROP DATABASE IF EXISTS " + quoteIdent(dbName))
}
})
for _, dbName := range dbNames {
if _, err := db.Exec("CREATE DATABASE " + quoteIdent(dbName) +
" CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
t.Fatal(err)
}
for _, table := range []string{"im_users", "im_auth", "im_credentials"} {
query := "CREATE TABLE " + quoteIdent(dbName) + "." + quoteIdent(table) +
" LIKE " + quoteIdent(baseDB) + "." + quoteIdent(table)
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
}
createTenant := func(code, dbName string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,db_name,state,created_by,updated_by) VALUES(?,?,?,?,?,?)",
code+suffix, code, dbName, types.TenantStateActive, 0, 0)
if err != nil {
t.Fatal(err)
}
@@ -68,44 +178,41 @@ func TestTenantAuthIsolation(t *testing.T) {
}
return types.TenantID(id)
}
tenantA := createTenant("test-im_auth-a-", dbNames[0])
tenantB := createTenant("test-im_auth-b-", dbNames[1])
businessA, _, err := router.TenantAdapterFor(tenantA)
if err != nil {
t.Fatal(err)
}
if reflect.ValueOf(businessA).Pointer() == reflect.ValueOf(adp).Pointer() {
t.Skip("tenant_db_routing is disabled in the MySQL test configuration")
}
tenantA := createTenant("test-auth-a-")
tenantB := createTenant("test-auth-b-")
userAID := now.UnixNano() & 0x3fffffffffffffff
userBID := userAID + 1
t.Cleanup(func() {
db.Exec("DELETE FROM credentials WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM auth WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM users WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM im_tenant WHERE id IN (?,?)", tenantA, tenantB)
})
for tenantID, userID := range map[types.TenantID]int64{tenantA: userAID, tenantB: userBID} {
_, err := db.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) "+
"VALUES(?,?,?,?,?,?,?,?,?)", userID, tenantID, now, now, types.StateOK, `{}`, nil, nil, nil)
if err != nil {
for index, userID := range []int64{userAID, userBID} {
tenantDB := quoteIdent(dbNames[index])
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,public,trusted,tags) "+
"VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, nil, nil, nil); err != nil {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?,?)",
tenantID, "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash"))
if err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)",
"basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES(?,?,?,?,?,?,?,?,?)", tenantID, now, now, "tel", "13800000000", "tel:13800000000",
userID, "", true)
if err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES(?,?,?,?,?,?,?,?)", now, now, "tel", "13800000000", "tel:13800000000", userID, "", true); err != nil {
t.Fatal(err)
}
}
uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
if err != nil || uidA != store.EncodeUid(userAID) {
t.Fatalf("tenant A auth lookup: uid=%v err=%v", uidA, err)
t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err)
}
uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
if err != nil || uidB != store.EncodeUid(userBID) {
t.Fatalf("tenant B auth lookup: uid=%v err=%v", uidB, err)
t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err)
}
credA, err := tenantAdp.TenantUserGetByCred(tenantA, "tel", "13800000000")
if err != nil || credA != uidA {
@@ -118,18 +225,10 @@ func TestTenantAuthIsolation(t *testing.T) {
if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
t.Fatalf("cross-tenant auth update: got %v, want %v", err, types.ErrNotFound)
t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound)
}
}
func TestCreateDb(t *testing.T) {
if err := adp.CreateDb(config.Reset); err != nil {
t.Fatal(err)
}
// Saved db is closed, get a fresh one.
db = adp.GetTestDB().(*sqlx.DB)
}
// ================== Create tests ================================
func TestUserCreate(t *testing.T) {
for _, user := range testData.Users {
@@ -142,12 +241,12 @@ func TestUserCreate(t *testing.T) {
if err := db.Ping(); err != nil {
logs.Err.Println("Database ping failed:", err)
}
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
err := db.QueryRow("SELECT COUNT(*) FROM im_users").Scan(&count)
if err != nil {
t.Error(err)
}
if count == 0 {
t.Error("No users created!")
t.Error("No im_users created!")
}
}
@@ -245,7 +344,7 @@ func TestTopicCreateP2P(t *testing.T) {
}
var got types.Subscription
err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
&got.UpdatedAt, &got.DeletedAt, &got.User, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &got.ModeWant, &got.ModeGiven, &got.Private)
if err != nil {
@@ -346,7 +445,7 @@ func TestUserGetAll(t *testing.T) {
t.Fatal(err)
}
if len(got) > 0 {
t.Error("result users should be zero length, got", len(got))
t.Error("result im_users should be zero length, got", len(got))
}
got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
@@ -698,7 +797,7 @@ func TestUserUpdate(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow("SELECT useragent, updatedat, createdat FROM users WHERE id=?", decodeUid(testData.Users[0].Id)).
err = db.QueryRow("SELECT useragent, updatedat, createdat FROM im_users WHERE id=?", decodeUid(testData.Users[0].Id)).
Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -775,7 +874,7 @@ func TestCredFail(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow("SELECT retries, updatedat, createdat FROM credentials WHERE userid=? AND method=? AND value=?",
err = db.QueryRow("SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=? AND method=? AND value=?",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -800,7 +899,7 @@ func TestCredConfirm(t *testing.T) {
CreatedAt time.Time
Done bool
}
err = db.QueryRow("SELECT updatedat, createdat, done FROM credentials WHERE userid=? AND method=? AND value=?",
err = db.QueryRow("SELECT updatedat, createdat, done FROM im_credentials WHERE userid=? AND method=? AND value=?",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
if err != nil {
t.Fatal(err)
@@ -822,7 +921,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Fatal(err)
}
var got []byte
err = db.QueryRow("SELECT secret FROM auth WHERE uname=?", rec.Unique).Scan(&got)
err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -839,12 +938,12 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM auth WHERE uname=?", rec.Unique).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("Old auth record not deleted")
t.Error("Old im_auth record not deleted")
}
}
@@ -863,7 +962,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
TouchedAt time.Time
SeqId int
}
err = db.QueryRow("SELECT touchedat, seqid FROM topics WHERE name=?", testData.Topics[2].Id).
err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
if err != nil {
t.Fatal(err)
@@ -883,7 +982,7 @@ func TestTopicUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow("SELECT updatedat FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow("SELECT updatedat FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -898,7 +997,7 @@ func TestTopicOwnerChange(t *testing.T) {
t.Fatal(err)
}
var got int64
err = db.QueryRow("SELECT owner FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow("SELECT owner FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -917,7 +1016,7 @@ func TestSubsUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -930,7 +1029,7 @@ func TestSubsUpdate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? LIMIT 1",
err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? LIMIT 1",
testData.Topics[1].Id).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -946,7 +1045,7 @@ func TestSubsDelete(t *testing.T) {
t.Fatal(err)
}
var deletedat sql.NullTime
err = db.QueryRow("SELECT deletedat FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
if err != nil {
t.Fatal(err)
@@ -965,7 +1064,7 @@ func TestDeviceUpsert(t *testing.T) {
DeviceId string
Platform string
}
err = db.QueryRow("SELECT deviceid, platform FROM devices WHERE userid=? LIMIT 1",
err = db.QueryRow("SELECT deviceid, platform FROM im_devices WHERE userid=? LIMIT 1",
decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
if err != nil {
t.Fatal(err)
@@ -980,7 +1079,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?",
decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -994,7 +1093,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?",
decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -1030,7 +1129,7 @@ func TestMessageAttachments(t *testing.T) {
}
// Check if attachments were linked (this would require checking filemsglinks table)
var count int
if err = db.QueryRow("SELECT COUNT(*) FROM filemsglinks WHERE msgid=?",
if err = db.QueryRow("SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=?",
types.ParseUid(testData.Msgs[1].Id)).Scan(&count); err != nil {
t.Fatal(err)
}
@@ -1066,7 +1165,7 @@ func TestDeviceDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1078,7 +1177,7 @@ func TestDeviceDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1154,7 +1253,7 @@ func TestCredDel(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1166,7 +1265,7 @@ func TestCredDel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1218,13 +1317,13 @@ func TestMessageDeleteList(t *testing.T) {
// Check messages in dellog
var count int
err = db.QueryRow("SELECT COUNT(*) FROM dellog WHERE topic=? AND deletedfor=?",
err = db.QueryRow("SELECT COUNT(*) FROM im_dellog WHERE topic=? AND deletedfor=?",
toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count == 0 {
t.Error("No dellog entries created")
t.Error("No im_dellog entries created")
}
// Hard delete test
@@ -1244,7 +1343,7 @@ func TestMessageDeleteList(t *testing.T) {
}
// Check if messages content was cleared
err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=? AND content IS NOT NULL",
err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=? AND content IS NOT NULL",
toDel.Topic).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1257,7 +1356,7 @@ func TestMessageDeleteList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1272,7 +1371,7 @@ func TestTopicDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow("SELECT state FROM topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
err = db.QueryRow("SELECT state FROM im_topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
if err != nil {
t.Fatal(err)
}
@@ -1286,7 +1385,7 @@ func TestTopicDelete(t *testing.T) {
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1311,7 +1410,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow("SELECT state FROM users WHERE id=?",
err = db.QueryRow("SELECT state FROM im_users WHERE id=?",
decodeUid(testData.Users[0].Id)).Scan(&state)
if err != nil {
t.Fatal(err)
@@ -1325,7 +1424,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE id=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
File diff suppressed because it is too large Load Diff
+45 -45
View File
@@ -66,12 +66,12 @@ func TestUserCreate(t *testing.T) {
}
var count int
err := db.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
err := db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users").Scan(&count)
if err != nil {
t.Error(err)
}
if count == 0 {
t.Error("No users created!")
t.Error("No im_users created!")
}
}
@@ -97,7 +97,7 @@ func TestCredUpsert(t *testing.T) {
t.Error("Should return duplicate error but got", err)
}
// Test add new unvalidated credentials
// Test add new unvalidated im_credentials
inserted, err := adp.CredUpsert(testData.Creds[3])
if err != nil {
t.Fatal(err)
@@ -183,7 +183,7 @@ func TestTopicCreateP2P(t *testing.T) {
var got types.Subscription
var userId int64
var modeWant, modeGiven []byte
err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
&got.UpdatedAt, &got.DeletedAt, &userId, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId,
&modeWant, &modeGiven, &got.Private)
@@ -231,7 +231,7 @@ func TestMessageSave(t *testing.T) {
}
}
// Some messages are soft deleted, but it's ignored by adp.MessageSave
// Some im_messages are soft deleted, but it's ignored by adp.MessageSave
for _, msg := range testData.Msgs {
if len(msg.DeletedFor) > 0 {
for _, del := range msg.DeletedFor {
@@ -284,7 +284,7 @@ func TestUserGetAll(t *testing.T) {
t.Fatal(err)
}
if len(got) > 0 {
t.Error("result users should be zero length, got", len(got))
t.Error("result im_users should be zero length, got", len(got))
}
got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
@@ -516,7 +516,7 @@ func TestChannelsForUser(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Should return empty slice since we don't have channel subscriptions in test data
// Should return empty slice since we don't have channel im_subscriptions in test data
if len(channels) != 0 {
t.Error(mismatchErrorString("Channels length", len(channels), 0))
}
@@ -663,7 +663,7 @@ func TestUserUpdate(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM im_users WHERE id=$1",
decodeUid(testData.Users[0].Id)).Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -726,9 +726,9 @@ func TestUserGetUnvalidated(t *testing.T) {
if err != nil {
t.Error(err)
}
// Should return empty slice since all test users are considered validated
// Should return empty slice since all test im_users are considered validated
if len(uids) > 0 {
t.Error("Expected no unvalidated users in test data")
t.Error("Expected no unvalidated im_users in test data")
}
}
@@ -744,7 +744,7 @@ func TestCredFail(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -769,7 +769,7 @@ func TestCredConfirm(t *testing.T) {
CreatedAt time.Time
Done bool
}
err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
if err != nil {
t.Fatal(err)
@@ -791,7 +791,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Fatal(err)
}
var got []byte
err = db.QueryRow(ctx, "SELECT secret FROM auth WHERE uname=$1", rec.Unique).Scan(&got)
err = db.QueryRow(ctx, "SELECT secret FROM im_auth WHERE uname=$1", rec.Unique).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -799,7 +799,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Error(mismatchErrorString("Secret", got, rec.Secret))
}
// Test with auth ID (unique) change
// Test with im_auth ID (unique) change
newId := "basic:bob12345"
err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
rec.AuthLvl, newSecret, rec.Expires)
@@ -808,12 +808,12 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM auth WHERE uname=$1", rec.Unique).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_auth WHERE uname=$1", rec.Unique).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("Old auth record not deleted")
t.Error("Old im_auth record not deleted")
}
}
@@ -832,7 +832,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
TouchedAt time.Time
SeqId int
}
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM topics WHERE name=$1", testData.Topics[2].Id).
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
if err != nil {
t.Fatal(err)
@@ -852,7 +852,7 @@ func TestTopicUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow(ctx, "SELECT updatedat FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow(ctx, "SELECT updatedat FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -870,11 +870,11 @@ func TestTopicUpdateSubCnt(t *testing.T) {
// Verify the subscription count was updated correctly
var subcnt int
err = db.QueryRow(ctx, "SELECT subcnt FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
err = db.QueryRow(ctx, "SELECT subcnt FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
if err != nil {
t.Fatal(err)
}
// Should match the number of active subscriptions
// Should match the number of active im_subscriptions
if subcnt < 0 {
t.Error("Subscription count should be non-negative")
}
@@ -886,7 +886,7 @@ func TestTopicOwnerChange(t *testing.T) {
t.Fatal(err)
}
var got int64
err = db.QueryRow(ctx, "SELECT owner FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow(ctx, "SELECT owner FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -905,7 +905,7 @@ func TestSubsUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -918,7 +918,7 @@ func TestSubsUpdate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 LIMIT 1",
err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 LIMIT 1",
testData.Topics[1].Id).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -934,7 +934,7 @@ func TestSubsDelete(t *testing.T) {
t.Fatal(err)
}
var deletedat sql.NullTime
err = db.QueryRow(ctx, "SELECT deletedat FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
if err != nil {
t.Fatal(err)
@@ -957,7 +957,7 @@ func TestDeviceUpsert(t *testing.T) {
DeviceId string
Platform string
}
err = db.QueryRow(ctx, "SELECT deviceid, platform FROM devices WHERE userid=$1 LIMIT 1",
err = db.QueryRow(ctx, "SELECT deviceid, platform FROM im_devices WHERE userid=$1 LIMIT 1",
decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
if err != nil {
t.Fatal(err)
@@ -972,7 +972,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT platform FROM devices WHERE userid=$1 AND deviceid=$2",
err = db.QueryRow(ctx, "SELECT platform FROM im_devices WHERE userid=$1 AND deviceid=$2",
decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -1001,7 +1001,7 @@ func TestMessageAttachments(t *testing.T) {
}
// Check if attachments were linked
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM filemsglinks WHERE msgid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=$1",
int64(types.ParseUid(testData.Msgs[1].Id))).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1036,9 +1036,9 @@ func TestDeviceGetAll(t *testing.T) {
if count < 1 {
t.Fatal(mismatchErrorString("count", count, ">=1"))
}
// Test that devices exist for the users
// Test that im_devices exist for the im_users
if len(gotDevs) == 0 {
t.Error("Expected devices for users")
t.Error("Expected im_devices for im_users")
}
}
@@ -1048,7 +1048,7 @@ func TestDeviceDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1 AND deviceid=$2",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1 AND deviceid=$2",
decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1061,13 +1061,13 @@ func TestDeviceDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1",
decodeUid(testData.Users[2].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("All devices not deleted:", count)
t.Error("All im_devices not deleted:", count)
}
}
@@ -1138,7 +1138,7 @@ func TestCredDel(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1150,7 +1150,7 @@ func TestCredDel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE userid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE userid=$1",
decodeUid(testData.Users[1].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1161,7 +1161,7 @@ func TestCredDel(t *testing.T) {
}
func TestAuthDelScheme(t *testing.T) {
// Test deleting auth scheme
// Test deleting im_auth scheme
err := adp.AuthDelScheme(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
if err != nil {
t.Fatal(err)
@@ -1207,15 +1207,15 @@ func TestMessageDeleteList(t *testing.T) {
t.Fatal(err)
}
// Check messages in dellog
// Check im_messages in im_dellog
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM dellog WHERE topic=$1 AND deletedfor=$2",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_dellog WHERE topic=$1 AND deletedfor=$2",
toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count == 0 {
t.Error("No dellog entries created")
t.Error("No im_dellog entries created")
}
// Hard delete test
@@ -1234,8 +1234,8 @@ func TestMessageDeleteList(t *testing.T) {
t.Fatal(err)
}
// Check if messages content was cleared
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1 AND content IS NOT NULL",
// Check if im_messages content was cleared
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1 AND content IS NOT NULL",
toDel.Topic).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1248,7 +1248,7 @@ func TestMessageDeleteList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1263,7 +1263,7 @@ func TestTopicDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow(ctx, "SELECT state FROM topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
err = db.QueryRow(ctx, "SELECT state FROM im_topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
if err != nil {
t.Fatal(err)
}
@@ -1277,7 +1277,7 @@ func TestTopicDelete(t *testing.T) {
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1302,7 +1302,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow(ctx, "SELECT state FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT state FROM im_users WHERE id=$1",
decodeUid(testData.Users[0].Id)).Scan(&state)
if err != nil {
t.Fatal(err)
@@ -1316,7 +1316,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users WHERE id=$1",
decodeUid(testData.Users[1].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
+2 -2
View File
@@ -28,13 +28,13 @@ import (
"github.com/tinode/chat/server/store/types"
)
func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop <-chan bool) error {
func listenAndServe(addr string, handler http.Handler, tlfConf *tls.Config, stop <-chan bool) error {
globals.shuttingDown = false
httpdone := make(chan bool)
server := &http.Server{
Handler: mux,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
WriteTimeout: 90 * time.Second,
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func stableIMEventID(parts ...any) string {
return types.StableEventID(parts...)
}
func enqueueIMBusinessEvent(tenantID types.TenantID, eventID, targetType, target string,
actor types.Uid, eventType string, payload any) error {
if tenantID.IsZero() || eventID == "" || target == "" || eventType == "" {
return types.ErrMalformed
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
err = store.HTTPInfrastructure.CreateEvent(tenantID, &types.BusinessEvent{
EventID: eventID, TargetType: targetType, Target: target, ActorUserID: actor,
EventType: eventType, Payload: encoded,
})
if err == nil {
dispatchIMBusinessEvents(tenantID)
}
return err
}
func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, eventType,
title, summary string, action types.KVMap) error {
if uid.IsZero() {
return types.ErrMalformed
}
return store.Security.CreateSystemNotification(tenantID, &types.SystemNotification{
TenantID: tenantID, UserID: uid, EventID: eventID, EventType: eventType,
Title: title, Summary: summary, Action: action,
})
}
func publishIMMeetingEvent(tenantID types.TenantID, actor types.Uid, meeting *types.Meeting, eventType string) error {
if meeting == nil {
return types.ErrMalformed
}
for _, participant := range meeting.Participants {
uid := participant.UserID
if uid.IsZero() {
continue
}
eventID := stableIMEventID("meeting", meeting.MeetingID, meeting.Version, eventType, uid.UserId())
if uid != actor {
if err := createIMSystemNotification(tenantID, uid, stableIMEventID(eventID, "notification"), eventType,
"会议通知", meeting.Title, types.KVMap{"meeting_id": meeting.MeetingID}); err != nil {
return err
}
}
if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, eventType,
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}); err != nil {
return err
}
}
return nil
}
func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error {
if target.IsZero() {
return types.ErrMalformed
}
eventID := stableIMEventID("relation", stableID, eventType, target.UserId())
if target != actor && title != "" {
if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), eventType,
title, summary, types.KVMap{"user_id": actor.UserId()}); err != nil {
return err
}
}
return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType,
map[string]any{"user_id": actor.UserId()})
}
func dispatchIMBusinessEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
return
}
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
if err != nil {
logs.Warn.Printf("business outbox: list tenant=%d: %v", tenantID, err)
return
}
for _, event := range events {
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
}
payload := json.RawMessage(event.Payload)
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
message := &ServerComMessage{
TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt,
Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload},
}
select {
case globals.hub.routeSrv <- message:
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
default:
logs.Warn.Printf("business outbox: hub queue full event=%s target=%s", event.EventID, strings.TrimSpace(event.Target))
return
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"strconv"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
// dispatchIMGroupEvents delivers committed outbox events to online topic
// sessions. Events remain pending when the hub is unavailable or its queue is
// full, so the next successful group mutation retries them.
func dispatchIMGroupEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.GroupBusiness == nil {
return
}
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.GroupBusiness.ListPendingEvents(tenantID, 100, now)
if err != nil {
logs.Warn.Printf("group outbox: list tenant=%d: %v", tenantID, err)
return
}
for _, event := range events {
members, memberErr := store.Topics.GetUsers(tenantID, event.Topic, &types.QueryOpt{Limit: 1000})
if memberErr != nil {
logs.Warn.Printf("group outbox: list members event=%s: %v", event.EventID, memberErr)
return
}
for _, member := range members {
uid := types.ParseUid(member.User)
if uid.IsZero() {
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
uid = store.EncodeUid(decoded)
}
}
if uid.IsZero() || uid == event.ActorUserID {
continue
}
if err = createIMSystemNotification(tenantID, uid,
stableIMEventID(event.EventID, uid.UserId(), "notification"), event.EventType,
"群聊动态", event.EventType, types.KVMap{"topic": event.Topic}); err != nil {
logs.Warn.Printf("group outbox: create notification event=%s user=%s: %v", event.EventID, uid.UserId(), err)
return
}
}
payload := json.RawMessage(event.Payload)
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
}
message := &ServerComMessage{
TenantID: tenantID,
RcptTo: event.Topic,
AsUser: from,
Timestamp: event.CreatedAt,
Info: &MsgServerInfo{
Topic: event.Topic,
From: from,
What: "group",
Event: event.EventType,
Payload: payload,
},
}
select {
case globals.hub.routeSrv <- message:
if err = store.GroupBusiness.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("group outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
default:
logs.Warn.Printf("group outbox: hub queue full event=%s", event.EventID)
return
}
}
}
+556 -225
View File
File diff suppressed because it is too large Load Diff
+302 -19
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -33,6 +34,57 @@ type testVerificationStore struct {
tokenHash string
}
type testHTTPInfrastructureStore struct {
records map[string]*types.HTTPIdempotencyRecord
events []types.BusinessEvent
}
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
if s.records == nil {
s.records = make(map[string]*types.HTTPIdempotencyRecord)
}
key := s.recordKey(record)
if existing := s.records[key]; existing != nil {
copy := *existing
copy.ResponseBody = append([]byte(nil), existing.ResponseBody...)
return &copy, false, nil
}
copy := *record
copy.State = types.HTTPIdempotencyProcessing
s.records[key] = &copy
return &copy, true, nil
}
func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
copy := *record
copy.State = types.HTTPIdempotencyCompleted
copy.ResponseBody = append([]byte(nil), record.ResponseBody...)
s.records[s.recordKey(record)] = &copy
return nil
}
func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
delete(s.records, s.recordKey(record))
return nil
}
func (s *testHTTPInfrastructureStore) CreateEvent(_ types.TenantID, event *types.BusinessEvent) error {
s.events = append(s.events, *event)
return nil
}
func (s *testHTTPInfrastructureStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.BusinessEvent, error) {
return append([]types.BusinessEvent(nil), s.events...), nil
}
func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
type testRelationsStore struct {
context *types.RelationshipContext
created *types.FriendRequest
@@ -149,6 +201,12 @@ type testGroupBusinessStore struct {
updatedMembers []types.GroupMemberPatch
}
func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any,
settings *types.GroupSettings, _ []types.Uid, _ any, _ time.Time) error {
s.settings = settings
return nil
}
func (s *testGroupBusinessStore) CreateSettings(_ types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
s.settings = settings
return settings, nil
@@ -225,7 +283,7 @@ func (s *testGroupBusinessStore) ListJoinRequests(_ types.TenantID, _ string, st
return out, "", nil
}
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, now time.Time) (*types.GroupJoinRequest, error) {
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, _ any, now time.Time) (*types.GroupJoinRequest, error) {
status := types.GroupJoinRequestStatusRejected
if action == "accept" {
status = types.GroupJoinRequestStatusAccepted
@@ -267,6 +325,61 @@ func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types.
return nil
}
func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, _ any, now time.Time) (bool, error) {
for _, invitation := range s.invitations {
if invitation.Topic == topic && invitation.TargetUserID == target {
return false, nil
}
}
s.invitations = append(s.invitations, types.GroupInvitation{Topic: topic, InviterUserID: inviter,
TargetUserID: target, Message: message, IdempotencyKey: idempotencyKey,
Status: types.GroupInvitationStatusJoined, CreatedAt: now, UpdatedAt: now})
return true, nil
}
func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, _ time.Time) error {
return nil
}
func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid,
role string, _ types.AccessMode, _ time.Time) error {
s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role})
s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive}
return nil
}
func (s *testGroupBusinessStore) TransferOwner(_ types.TenantID, topic string, _, target types.Uid, _ int, _ time.Time) error {
settings, _ := s.GetSettings(0, topic)
settings.OwnerUserID = target
s.settings = settings
return nil
}
func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
if admin {
return true, nil
}
if s.settings != nil && s.settings.Topic == topic && s.settings.MuteAll {
return false, nil
}
for _, mute := range s.mutes {
if mute.Topic == topic && mute.UserID == uid && mute.Status == types.GroupMuteStatusActive &&
(mute.ExpiresAt == nil || mute.ExpiresAt.After(now)) {
return false, nil
}
}
return true, nil
}
func (s *testGroupBusinessStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.GroupEvent, error) {
return nil, nil
}
func (s *testGroupBusinessStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
func defaultStringPtr(value *string, fallback string) string {
if value == nil {
return fallback
@@ -501,6 +614,12 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
if patch.AddMeMethods != nil {
privacy.AddMeMethods = patch.AddMeMethods
}
if patch.ProfileVisibility != nil {
privacy.ProfileVisibility = *patch.ProfileVisibility
}
if patch.LastSeenVisibility != nil {
privacy.LastSeenVisibility = *patch.LastSeenVisibility
}
if patch.ReadReceipts != nil {
privacy.ReadReceipts = *patch.ReadReceipts
}
@@ -821,6 +940,84 @@ func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantI
return mockStore
}
func TestIMHTTPProtocolHeaders(t *testing.T) {
apiKey := makeTestAPIKey(t)
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
called++
writeIMSuccess(wrt, req, map[string]any{"ok": true})
}))
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || called != 0 {
t.Fatalf("missing API key: status=%d called=%d", rec.Code, called)
}
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("server did not generate X-Request-ID")
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported {
t.Fatalf("missing schema: status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "request-1")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" {
t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID"))
}
}
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldInfrastructure := store.HTTPInfrastructure
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
defer func() { store.HTTPInfrastructure = oldInfrastructure }()
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
called++
body, _ := io.ReadAll(req.Body)
writeIMSuccess(wrt, req, map[string]any{"body": string(body), "call": called})
}))
apiKey := makeTestAPIKey(t)
request := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("X-Tinode-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
first := request(`{"value":1}`)
second := request(`{"value":1}`)
if first.Code != http.StatusOK || second.Code != http.StatusOK || called != 1 {
t.Fatalf("replay failed: first=%d second=%d called=%d", first.Code, second.Code, called)
}
if second.Header().Get("X-Idempotent-Replay") != "true" || first.Body.String() != second.Body.String() {
t.Fatalf("response was not replayed: first=%s second=%s", first.Body.String(), second.Body.String())
}
conflict := request(`{"value":2}`)
if conflict.Code != http.StatusConflict || decodeIMHTTPResponse(t, conflict).Reason != imReasonIdempotencyConflict || called != 1 {
t.Fatalf("conflict failed: status=%d called=%d body=%s", conflict.Code, called, conflict.Body.String())
}
}
func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
@@ -1364,11 +1561,16 @@ func TestServeIMWorkbench(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldWorkbench := store.Workbench
oldTenants := store.Tenants
store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"},
}}
defer func() { store.Workbench = oldWorkbench }()
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
defer func() {
store.Workbench = oldWorkbench
store.Tenants = oldTenants
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil)
req.Header.Set("Authorization", "Bearer token-1")
@@ -1391,6 +1593,10 @@ func TestServeIMWorkbench(t *testing.T) {
if item["code"] != "organization" || item["route"] != "/organization" {
t.Fatalf("unexpected module: %#v", item)
}
enterprise := data["enterprise"].(map[string]any)
if enterprise["tenant_code"] != "acme" {
t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"])
}
}
func TestServeIMOrganizationDepartments(t *testing.T) {
@@ -1838,6 +2044,43 @@ func TestServeIMMePrivacy(t *testing.T) {
}
}
func TestIMCanViewLastSeen(t *testing.T) {
oldProfiles := store.Profiles
oldRelations := store.Relations
defer func() {
store.Profiles = oldProfiles
store.Relations = oldRelations
}()
viewer, target := types.Uid(1), types.Uid(2)
profiles := &testProfilesStore{privacy: &types.UserPrivacySettings{
UserID: target, LastSeenVisibility: "everyone",
}}
store.Profiles = profiles
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipNone,
}}
if !imCanViewLastSeen(7, viewer, target) {
t.Fatal("everyone visibility must expose last seen")
}
profiles.privacy.LastSeenVisibility = "nobody"
if imCanViewLastSeen(7, viewer, target) {
t.Fatal("nobody visibility must hide last seen")
}
profiles.privacy.LastSeenVisibility = "friends"
if imCanViewLastSeen(7, viewer, target) {
t.Fatal("non-friend must not see friends-only last seen")
}
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipFriend,
}}
if !imCanViewLastSeen(7, viewer, target) {
t.Fatal("friend must see friends-only last seen")
}
}
func TestServeIMAppReleaseLatest(t *testing.T) {
oldTenants := store.Tenants
oldReleases := store.AppReleases
@@ -2118,13 +2361,16 @@ func TestServeIMMessageReceipts(t *testing.T) {
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldTopics := store.Topics
oldProfiles := store.Profiles
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
store.Profiles = &testProfilesStore{}
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
store.Profiles = oldProfiles
}()
mySub := &types.Subscription{
@@ -2326,15 +2572,6 @@ func TestServeIMGroupCreate(t *testing.T) {
store.GroupBusiness = oldGroupBusiness
}()
topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn(
func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error {
if topic.Id != "grpnewgroup" {
t.Fatalf("unexpected topic id: %s", topic.Id)
}
return nil
})
subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil)
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil)
topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpnewgroup"},
TenantID: 7,
@@ -2475,9 +2712,6 @@ func TestServeIMGroupLeave(t *testing.T) {
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil)
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -2493,6 +2727,46 @@ func TestServeIMGroupLeave(t *testing.T) {
}
}
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
groupStore := &testGroupBusinessStore{}
store.Subs = subs
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
owner := types.Uid(42)
target := types.Uid(43)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "invite-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" {
t.Fatalf("unexpected invitation response: %#v", data)
}
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target {
t.Fatalf("invitation was not persisted: %#v", groupStore.invitations)
}
}
func TestServeIMGroupQRCodesAndResolve(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2595,7 +2869,8 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
store.GroupBusiness = oldGroupBusiness
}()
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(2)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(nil, nil)
annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`))
annReq.Header.Set("Authorization", "Bearer token-1")
@@ -2663,10 +2938,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
members := []types.Subscription{*ownerSub, member}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3)
topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2)
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil)
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil)
topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(nil)
adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`))
adminReq.Header.Set("Authorization", "Bearer token-1")
@@ -3236,6 +3508,7 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) {
store.Store = mockStore
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Users = users
owner := types.Uid(1)
@@ -3258,10 +3531,12 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) {
Relationship: types.RelationshipNone,
CanSend: false,
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Store = oldStore
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`))
@@ -3447,9 +3722,14 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1)
mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{}
store.Relations = relations
defer func() { store.Relations = oldRelations }()
store.Profiles = &testProfilesStore{}
defer func() {
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`))
@@ -3643,6 +3923,7 @@ func TestServeIMUserProfile(t *testing.T) {
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
peer := types.Uid(2)
users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{
@@ -3659,9 +3940,11 @@ func TestServeIMUserProfile(t *testing.T) {
Relationship: types.RelationshipFriend,
CanSend: true,
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil)
+1 -1
View File
@@ -853,7 +853,7 @@ func main() {
mux.HandleFunc("/", serve404)
}
if err = listenAndServe(config.Listen, mux, tlsConfig, signalHandler()); err != nil {
if err = listenAndServe(config.Listen, withIMHTTPProtocol(config.ApiPath+"v1/im/", mux), tlsConfig, signalHandler()); err != nil {
logs.Err.Fatal(err)
}
}
+7
View File
@@ -17,6 +17,13 @@ This adapter requires you to obtain your own credentials from Goole Firebase. If
Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa.
4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications
The server may also enable FCM without storing credentials in `tinode.conf`:
* `IM_FCM_CREDENTIALS_FILE` points to the service-account JSON file.
* `IM_FCM_CREDENTIALS_JSON` contains the service-account JSON directly.
Either variable enables the adapter even when the checked-in configuration has `enabled: false`.
### iOS and Android
1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.
+15 -2
View File
@@ -123,7 +123,7 @@ func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Me
skipDevices[deviceID] = struct{}{}
}
}
devices, count, err = store.Devices.GetAll(rcpt.TenantID, uids...)
devices, count, err = pushDevicesForUsers(rcpt.TenantID, uids...)
if err != nil {
logs.Warn.Println("fcm push: db error", err)
return nil, nil
@@ -218,7 +218,7 @@ func TenantChannelName(tenantID t.TenantID, channel string) string {
// DevicesForUser loads device IDs of the given user.
func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
ddef, count, err := store.Devices.GetAll(tenantID, uid)
ddef, count, err := pushDevicesForUsers(tenantID, uid)
if err != nil {
logs.Warn.Println("fcm devices for user: db error", err)
return nil
@@ -235,6 +235,19 @@ func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
return devices
}
func pushDevicesForUsers(tenantID t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenQueryInterface); ok {
devices, count, err := tokens.GetAll(tenantID, uids...)
if err != nil {
return nil, 0, err
}
if count > 0 {
return devices, count, nil
}
}
return store.Devices.GetAll(tenantID, uids...)
}
// ChannelsForUser loads user's channel subscriptions with P permission.
func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string {
channels, err := store.Users.GetChannels(tenantID, uid)
+65
View File
@@ -0,0 +1,65 @@
package fcm
import (
"testing"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testProductPushTokens struct {
devices map[types.Uid][]types.DeviceDef
}
func (s *testProductPushTokens) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil
}
func (s *testProductPushTokens) GetAll(_ types.TenantID, _ ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
count := 0
for _, devices := range s.devices {
count += len(devices)
}
return s.devices, count, nil
}
type testLegacyPushDevices struct {
called bool
}
func (*testLegacyPushDevices) Update(types.TenantID, types.Uid, string, *types.DeviceDef) error {
return nil
}
func (s *testLegacyPushDevices) GetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
s.called = true
return nil, 0, nil
}
func (*testLegacyPushDevices) Delete(types.TenantID, types.Uid, string) error { return nil }
func TestPushDevicesForUsersPrefersProductTokens(t *testing.T) {
oldTokens := store.DevicePushTokens
oldDevices := store.Devices
defer func() {
store.DevicePushTokens = oldTokens
store.Devices = oldDevices
}()
uid := types.Uid(42)
legacy := &testLegacyPushDevices{}
store.DevicePushTokens = &testProductPushTokens{devices: map[types.Uid][]types.DeviceDef{
uid: {{TenantID: 7, DeviceId: "product-token", Platform: "android"}},
}}
store.Devices = legacy
devices, count, err := pushDevicesForUsers(7, uid)
if err != nil {
t.Fatal(err)
}
if count != 1 || devices[uid][0].DeviceId != "product-token" {
t.Fatalf("unexpected devices: %#v count=%d", devices, count)
}
if legacy.called {
t.Fatal("legacy device store must not be read when product tokens exist")
}
}
+8
View File
@@ -70,6 +70,14 @@ func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
return false, errors.New("failed to parse config: " + err.Error())
}
if raw := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_JSON")); raw != "" {
config.Credentials = json.RawMessage(raw)
config.Enabled = true
}
if path := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_FILE")); path != "" {
config.CredentialsFile = path
config.Enabled = true
}
if !config.Enabled {
return false, nil
}
+4
View File
@@ -23,6 +23,10 @@ func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
return s.tenant, s.err
}
func (s testTenantStore) GetByID(types.TenantID) (*types.Tenant, error) {
return s.tenant, s.err
}
func test_makeSession(uid types.Uid) *Session {
return &Session{
send: make(chan any, 10),
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type HTTPInfrastructurePersistenceInterface interface {
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
CreateEvent(types.TenantID, *types.BusinessEvent) error
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type httpInfrastructureMapper struct{}
var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{}
func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
infrastructure, ok := business.(adapter.TenantHTTPInfrastructureAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return infrastructure, nil
}
func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record)
}
func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record)
}
func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
}
func (httpInfrastructureMapper) CreateEvent(tenantID types.TenantID, event *types.BusinessEvent) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventCreate(tenantID, event)
}
func (httpInfrastructureMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.BusinessEvent, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantBusinessEventListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
}
+241 -10
View File
@@ -129,6 +129,7 @@ type storeObj struct{}
// TenantsPersistenceInterface defines tenant registry lookups used before authentication.
type TenantsPersistenceInterface interface {
GetByCode(code string) (*types.Tenant, error)
GetByID(id types.TenantID) (*types.Tenant, error)
}
type tenantsMapper struct{}
@@ -145,6 +146,18 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
return tenantAdp.TenantGetByCode(code)
}
// GetByID resolves an internal tenant ID to its registry record.
func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return tenantAdp.TenantGetByID(id)
}
func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
@@ -1080,6 +1093,7 @@ func (callsMapper) List(tenantID types.TenantID, uid types.Uid, topic, cursor st
}
type GroupBusinessPersistenceInterface interface {
CreateGroup(types.TenantID, *types.Topic, types.Uid, any, *types.GroupSettings, []types.Uid, any, time.Time) error
CreateSettings(types.TenantID, *types.GroupSettings) (*types.GroupSettings, error)
GetSettings(types.TenantID, string) (*types.GroupSettings, error)
UpdateSettings(types.TenantID, string, types.GroupSettingsPatch) (*types.GroupSettings, error)
@@ -1090,19 +1104,41 @@ type GroupBusinessPersistenceInterface interface {
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error)
ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, time.Time) (*types.GroupJoinRequest, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error)
LatestAnnouncement(types.TenantID, string) (*types.GroupAnnouncement, error)
PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error)
ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error)
Leave(types.TenantID, string, types.Uid, time.Time) error
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
TransferOwner(types.TenantID, string, types.Uid, types.Uid, int, time.Time) error
CanPost(types.TenantID, string, types.Uid, bool, time.Time) (bool, error)
ListPendingEvents(types.TenantID, int, time.Time) ([]types.GroupEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type groupBusinessMapper struct{}
var GroupBusiness GroupBusinessPersistenceInterface
func (groupBusinessMapper) CreateGroup(tenantID types.TenantID, topic *types.Topic, owner types.Uid, ownerPrivate any,
settings *types.GroupSettings, members []types.Uid, memberPrivate any, now time.Time) error {
if topic == nil || topic.TenantID != tenantID || owner.IsZero() || settings == nil || settings.Topic != topic.Id {
return types.ErrMalformed
}
topic.InitTimes()
topic.TouchedAt = topic.CreatedAt
topic.Owner = owner.String()
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupCreate(tenantID, topic, owner, ownerPrivate, settings, members, memberPrivate, now)
}
func (groupBusinessMapper) CreateSettings(tenantID types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
if settings == nil || settings.Topic == "" || settings.OwnerUserID.IsZero() || settings.CreatedBy.IsZero() {
return nil, types.ErrMalformed
@@ -1219,7 +1255,7 @@ func (groupBusinessMapper) ListJoinRequests(tenantID types.TenantID, topic, stat
return tenantAdp.TenantGroupJoinRequestList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(status), strings.TrimSpace(cursor), limit)
}
func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, now time.Time) (*types.GroupJoinRequest, error) {
func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*types.GroupJoinRequest, error) {
if strings.TrimSpace(topic) == "" || strings.TrimSpace(requestID) == "" || actor.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
@@ -1227,7 +1263,7 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, now)
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
}
func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) {
@@ -1299,6 +1335,87 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid
return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now)
}
func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
}
func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupLeave(tenantID, strings.TrimSpace(topic), uid, now)
}
func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, actor, target types.Uid,
role string, modeGiven types.AccessMode, now time.Time) error {
if strings.TrimSpace(topic) == "" || actor.IsZero() || target.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupRoleUpdate(tenantID, strings.TrimSpace(topic), actor, target, strings.TrimSpace(role), modeGiven, now)
}
func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string, previousOwner, newOwner types.Uid,
version int, now time.Time) error {
if strings.TrimSpace(topic) == "" || previousOwner.IsZero() || newOwner.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupOwnerTransfer(tenantID, strings.TrimSpace(topic), previousOwner, newOwner, version, now)
}
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantGroupCanPost(tenantID, strings.TrimSpace(topic), uid, admin, now)
}
func (groupBusinessMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.GroupEvent, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupEventListPending(tenantID, limit, now)
}
func (groupBusinessMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
if strings.TrimSpace(eventID) == "" || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupEventMarkDelivered(tenantID, strings.TrimSpace(eventID), now)
}
type WorkbenchPersistenceInterface interface {
Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error)
}
@@ -1724,6 +1841,10 @@ type DevicePushTokenPersistenceInterface interface {
Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error)
}
type DevicePushTokenQueryInterface interface {
GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
}
type devicePushTokenMapper struct{}
var DevicePushTokens DevicePushTokenPersistenceInterface
@@ -1740,6 +1861,20 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device
return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token)
}
func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, 0, err
}
reader, ok := tenantAdp.(interface {
TenantDevicePushTokenGetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
})
if !ok {
return map[types.Uid][]types.DeviceDef{}, 0, nil
}
return reader.TenantDevicePushTokenGetAll(tenantID, uids...)
}
type SafetyPersistenceInterface interface {
CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error)
ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error)
@@ -1863,7 +1998,7 @@ func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.
}
func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) {
if uid.IsZero() || patch.Version <= 0 {
if uid.IsZero() || patch.Version <= 0 || !validUserPrivacyPatch(patch) {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1873,6 +2008,31 @@ func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch
return tenantAdp.TenantUserPrivacyUpdate(tenantID, uid, patch)
}
func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool {
if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil {
return false
}
for method := range patch.AddMeMethods {
switch method {
case "account", "phone", "qr", "group":
default:
return false
}
}
validVisibility := func(value *string) bool {
if value == nil {
return true
}
switch strings.TrimSpace(*value) {
case "everyone", "friends", "nobody":
return true
default:
return false
}
}
return validVisibility(patch.ProfileVisibility) && validVisibility(patch.LastSeenVisibility)
}
func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil {
return false
@@ -2321,20 +2481,77 @@ func normalizeClientMsgID(head types.KVMap) (string, error) {
func validateMessageHeaders(head types.KVMap) error {
if schema, ok := head["x-im-schema"]; ok {
value, ok := schema.(string)
if !ok || !validIMMessageHeaderToken(value, 32) {
if !validIMMessageSchema(schema) {
return types.ErrMalformed
}
}
if msgType, ok := head["type"]; ok {
value, ok := msgType.(string)
if !ok || !validIMMessageType(value) {
legacyType, hasLegacyType := messageHeaderString(head, "type")
productType, hasProductType := messageHeaderString(head, "x-im-type")
if raw, ok := head["type"]; ok && (!hasLegacyType || !validIMMessageType(legacyType)) {
_ = raw
return types.ErrMalformed
}
if raw, ok := head["x-im-type"]; ok && (!hasProductType || !validIMMessageType(productType)) {
_ = raw
return types.ErrMalformed
}
if hasLegacyType && hasProductType {
if canonicalIMMessageType(legacyType) != canonicalIMMessageType(productType) {
return types.ErrMalformed
}
}
return nil
}
func validIMMessageSchema(value any) bool {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed) == "1"
case json.Number:
parsed, err := typed.Int64()
return err == nil && parsed == 1
case float64:
return typed == 1
case float32:
return typed == 1
case int:
return typed == 1
case int8:
return typed == 1
case int16:
return typed == 1
case int32:
return typed == 1
case int64:
return typed == 1
case uint:
return typed == 1
case uint8:
return typed == 1
case uint16:
return typed == 1
case uint32:
return typed == 1
case uint64:
return typed == 1
default:
return false
}
}
func messageHeaderString(head types.KVMap, key string) (string, bool) {
value, ok := head[key]
if !ok {
return "", false
}
text, ok := value.(string)
if !ok {
return "", false
}
text = strings.TrimSpace(text)
return text, text != ""
}
func validIMMessageHeaderToken(value string, max int) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > max {
@@ -2351,13 +2568,27 @@ func validIMMessageHeaderToken(value string, max int) bool {
func validIMMessageType(value string) bool {
switch strings.TrimSpace(value) {
case "text", "image", "file", "audio", "video", "location", "contact", "card", "call", "system":
case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card",
"card", "link", "forward_bundle", "sticker", "call", "system", "system_event":
return true
default:
return false
}
}
func canonicalIMMessageType(value string) string {
switch strings.TrimSpace(value) {
case "audio":
return "voice"
case "contact":
return "contact_card"
case "system":
return "system_event"
default:
return strings.TrimSpace(value)
}
}
// DeleteList deletes multiple messages defined by a list of ranges.
func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
+28
View File
@@ -1,6 +1,7 @@
package store
import (
"encoding/json"
"testing"
"github.com/tinode/chat/server/store/types"
@@ -11,3 +12,30 @@ func TestForTenantRejectsZero(t *testing.T) {
t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err)
}
}
func TestValidateMessageHeadersProductContract(t *testing.T) {
valid := []types.KVMap{
{"x-im-schema": float64(1), "x-im-type": "text"},
{"x-im-schema": json.Number("1"), "x-im-type": "voice"},
{"x-im-schema": "1", "x-im-type": "contact_card"},
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"},
{"type": "system"},
}
for _, head := range valid {
if err := validateMessageHeaders(head); err != nil {
t.Fatalf("validateMessageHeaders(%v) = %v, want nil", head, err)
}
}
invalid := []types.KVMap{
{"x-im-schema": float64(2), "x-im-type": "text"},
{"x-im-schema": true, "x-im-type": "text"},
{"x-im-schema": float64(1), "x-im-type": "unknown"},
{"type": "text", "x-im-type": "image"},
}
for _, head := range invalid {
if err := validateMessageHeaders(head); err != types.ErrMalformed {
t.Fatalf("validateMessageHeaders(%v) = %v, want malformed", head, err)
}
}
}
+16
View File
@@ -180,3 +180,19 @@ type GroupMute struct {
MutedByRaw int64 `db:"muted_by"`
RevokedRaw *int64 `db:"revoked_by"`
}
type GroupEvent struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
Topic string `db:"topic"`
ActorUserID Uid `db:"-"`
EventType string `db:"event_type"`
Payload []byte `db:"payload"`
Status string `db:"status"`
Attempts int `db:"attempts"`
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ActorRaw int64 `db:"actor_user_id"`
}
+56
View File
@@ -0,0 +1,56 @@
package types
import (
"crypto/sha256"
"fmt"
"time"
)
const (
HTTPIdempotencyProcessing = "processing"
HTTPIdempotencyCompleted = "completed"
BusinessEventPending = "pending"
BusinessEventDelivered = "delivered"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
type HTTPIdempotencyRecord struct {
UserID Uid
Method string
Route string
Key string
BodyHash string
State string
ResponseStatus int
ResponseBody []byte
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
// BusinessEvent is a durable event destined for an online user or topic.
type BusinessEvent struct {
ID int64
EventID string
TargetType string
Target string
ActorUserID Uid
EventType string
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time
DeliveredAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string {
hash := sha256.New()
for _, part := range parts {
_, _ = fmt.Fprint(hash, part, "\x00")
}
return fmt.Sprintf("evt_%x", hash.Sum(nil)[:24])
}
+22 -3
View File
@@ -1047,7 +1047,10 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
delete(head, "sender")
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
if msgType, _ := head["type"].(string); msgType == "system" {
legacyType, _ := head["type"].(string)
productType, _ := head["x-im-type"].(string)
if legacyType == "system" || legacyType == "system_event" ||
productType == "system" || productType == "system_event" {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
@@ -1060,6 +1063,20 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
}
head = normalized
}
if t.cat == types.TopicCatGrp && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.GroupBusiness != nil && store.Store != nil && store.Store.IsOpen() {
access := pud.modeWant & pud.modeGiven
canPost, err := store.GroupBusiness.CanPost(t.tenantID, t.name, asUid, access.IsAdmin(), msg.Timestamp)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to check group mute policy: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
return err
}
if !canPost {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
}
if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.Relations != nil && store.Store != nil && store.Store.IsOpen() {
peer := t.p2pOtherUser(asUid)
@@ -2301,7 +2318,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
// because to stay in memory at least one of the users must be connected to topic.
// FIXME(gene): it breaks when user A stays active in one session and connects-disconnects
// from another session. The second session will not see correct LastSeen time and UserAgent.
if pud.lastSeen != nil {
if pud.lastSeen != nil && imCanViewLastSeen(t.tenantID, asUid, t.p2pOtherUser(asUid)) {
desc.LastSeen = &MsgLastSeenInfo{
When: pud.lastSeen,
UserAgent: pud.lastUA,
@@ -2733,8 +2750,10 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
// Reporting user's subscriptions to other topics. P2P topic name is the
// UID of the other user.
with := sub.GetWith()
canViewLastSeen := true
if with != "" {
mts.Topic = with
canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with))
mts.Online = t.perSubs[with].online && !deleted && presencer
} else if strings.HasPrefix(sub.Topic, "slf") {
mts.Topic = "slf"
@@ -2759,7 +2778,7 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
}
lastSeen := sub.GetLastSeen()
if lastSeen != nil && !mts.Online {
if lastSeen != nil && !mts.Online && canViewLastSeen {
mts.LastSeen = &MsgLastSeenInfo{
When: lastSeen,
UserAgent: sub.GetUserAgent(),
+35 -1
View File
@@ -334,6 +334,40 @@ func TestHandleBroadcastDataP2PRequiresRelationship(t *testing.T) {
}
}
func TestHandleBroadcastDataGroupRejectsMutedMember(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, 2, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
mockStore := mock_store.NewMockPersistentStorageInterface(helper.ctrl)
mockStore.EXPECT().IsOpen().Return(true)
store.Store = mockStore
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{Topic: "grp-test", MuteAll: true}}
memberState := helper.topic.perUser[helper.uids[1]]
memberState.modeWant = types.ModeCPublic
memberState.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[1]] = memberState
defer func() {
store.Store = oldStore
store.GroupBusiness = oldGroupBusiness
}()
msg := &ClientComMessage{
Id: "muted-1", AsUser: helper.uids[1].UserId(), Original: "grp-test",
Pub: &MsgClientPub{Topic: "grp-test", Content: "blocked"},
sess: helper.sessions[1],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[1], []int{http.StatusForbidden}, t)
if helper.topic.lastID != 0 {
t.Fatalf("muted publish changed topic sequence: %d", helper.topic.lastID)
}
}
func TestHandleBroadcastCall(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
@@ -766,7 +800,7 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
AsUser: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"type": "system"},
Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"},
Content: "test",
},
sess: helper.sessions[0],