feat: complete IM P0 and P1 reliability work
This commit is contained in:
+21
-1
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+406
-121
File diff suppressed because it is too large
Load Diff
@@ -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(¤t)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanGroupJoinRequest(¤t)
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+292
-292
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+302
-19
@@ -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 ©, false, nil
|
||||
}
|
||||
copy := *record
|
||||
copy.State = types.HTTPIdempotencyProcessing
|
||||
s.records[key] = ©
|
||||
return ©, 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)] = ©
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user