//go:build mysql // +build mysql // Package mysql is a database adapter for MySQL. package mysql import ( "context" "database/sql" "encoding/json" "errors" "hash/fnv" "sort" "strconv" "strings" "sync" "time" ms "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" "github.com/tinode/chat/server/auth" dbadapter "github.com/tinode/chat/server/db" "github.com/tinode/chat/server/db/common" "github.com/tinode/chat/server/store" t "github.com/tinode/chat/server/store/types" ) // adapter holds MySQL connection data. type adapter struct { db *sqlx.DB registryDB *sqlx.DB dsn string dbName string // Maximum number of records to return maxResults int // Maximum number of message records to return maxMessageResults int version int // Single query timeout. sqlTimeout time.Duration // DB transaction timeout. txTimeout time.Duration baseConfig *configType tenantDBRouting bool tenantScopedDB bool tenantID t.TenantID tenantRegistryDB string tenantAdapters map[t.TenantID]*adapter tenantMu sync.Mutex groupShardWindow string } const ( adpVersion = 123 adapterName = "mysql" defaultDSN = "root:@tcp(localhost:3306)/meeting_im?parseTime=true" defaultDatabase = "meeting_im" defaultMaxResults = 1024 // This is capped by the Session's send queue limit (128). defaultMaxMessageResults = 100 // If DB request timeout is specified, // we allocate txTimeoutMultiplier times more time for transactions. txTimeoutMultiplier = 1.5 ) const defaultTenantID = t.TenantID(1) func quoteIdent(name string) string { return "`" + strings.ReplaceAll(name, "`", "``") + "`" } func parseFileUID(fid string) t.Uid { name := strings.TrimSpace(fid) id := t.ParseUid(name) if !id.IsZero() { return id } if dot := strings.LastIndexByte(name, '.'); dot > 0 { ext := name[dot+1:] if len(ext) == 0 || len(ext) > 16 { return t.ZeroUid } for _, r := range ext { if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') && (r < '0' || r > '9') { return t.ZeroUid } } return t.ParseUid(name[:dot]) } return t.ZeroUid } type configType struct { // DB connection settings. // See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config // for the full list of fields. ms.Config // Deprecated. DSN string `json:"dsn,omitempty"` // Connection pool settings. // // Maximum number of open connections to the database. MaxOpenConns int `json:"max_open_conns,omitempty"` // Maximum number of connections in the idle connection pool. MaxIdleConns int `json:"max_idle_conns,omitempty"` // Maximum amount of time a connection may be reused (in seconds). ConnMaxLifetime int `json:"conn_max_lifetime,omitempty"` // DB request timeout (in seconds). // If 0 (or negative), no timeout is applied. SqlTimeout int `json:"sql_timeout,omitempty"` // TenantRegistryDB is the public backend database, e.g. hotgo. TenantRegistryDB string `json:"tenant_registry_db,omitempty"` // TenantDBRouting enables "one tenant, one database" routing. TenantDBRouting bool `json:"tenant_db_routing,omitempty"` } func (a *adapter) getContext() (context.Context, context.CancelFunc) { if a.sqlTimeout > 0 { return context.WithTimeout(context.Background(), a.sqlTimeout) } return context.Background(), nil } func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) { if a.txTimeout > 0 { return context.WithTimeout(context.Background(), a.txTimeout) } return context.Background(), nil } func normalizeMySQLDSN(dsn string) string { dsn = strings.TrimSpace(dsn) if strings.HasPrefix(dsn, "mysql://") { return strings.TrimPrefix(dsn, "mysql://") } if strings.HasPrefix(dsn, "mysql:") { credentials, _, _ := strings.Cut(strings.TrimPrefix(dsn, "mysql:"), "@") if strings.Count(credentials, ":") >= 1 { return strings.TrimPrefix(dsn, "mysql:") } } return dsn } func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) { if strings.TrimSpace(dsn) != "" { cfg, err := ms.ParseDSN(normalizeMySQLDSN(dsn)) if err != nil { return nil, "", err } if dbName != "" { cfg.DBName = dbName } dsn = cfg.FormatDSN() dbName = cfg.DBName } else { cfg := config.Config if dbName != "" { cfg.DBName = dbName } if cfg.DBName == "" { cfg.DBName = defaultDatabase } dsn = cfg.FormatDSN() dbName = cfg.DBName } db, err := sqlx.Open(instrumentedMySQLDriverName, dsn) if err != nil { return nil, "", err } if config.MaxOpenConns > 0 { db.SetMaxOpenConns(config.MaxOpenConns) } if config.MaxIdleConns > 0 { db.SetMaxIdleConns(config.MaxIdleConns) } if config.ConnMaxLifetime > 0 { db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second) } return db, dbName, nil } func (a *adapter) HealthCheck(ctx context.Context) error { if a.db == nil { return errors.New("mysql database is not open") } if err := a.db.PingContext(ctx); err != nil { return err } if a.registryDB != nil { if err := a.registryDB.PingContext(ctx); err != nil { return errors.New("tenant registry mysql: " + err.Error()) } } return nil } // Open initializes database session func (a *adapter) Open(jsonconfig json.RawMessage) error { if a.db != nil { return errors.New("mysql adapter is already connected") } if len(jsonconfig) < 2 { return errors.New("adapter mysql missing config") } var err error defaultCfg := ms.NewConfig() config := configType{Config: *defaultCfg} if err = json.Unmarshal(jsonconfig, &config); err != nil { return errors.New("mysql adapter failed to parse config: " + err.Error()) } if dsn := config.FormatDSN(); dsn != defaultCfg.FormatDSN() { // MySql config is specified. Use it. a.dbName = config.DBName a.dsn = dsn if config.DSN != "" { return errors.New("mysql config: conflicting config and DSN are provided") } } else { // Otherwise, use DSN to configure database connection. // Note: this method is deprecated. if config.DSN != "" { // Remove optional schema. a.dsn = normalizeMySQLDSN(config.DSN) } else { a.dsn = defaultDSN } // Parse out the database name from the DSN. if cfg, err := ms.ParseDSN(a.dsn); err == nil { a.dbName = cfg.DBName } else { return err } } if a.dbName == "" { a.dbName = defaultDatabase } if strings.EqualFold(strings.TrimSpace(a.dbName), "tinode") { return errors.New("mysql database tinode is deprecated; use meeting_im") } config.Config.DBName = a.dbName a.baseConfig = &config a.tenantDBRouting = config.TenantDBRouting a.tenantID = defaultTenantID a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB) a.tenantAdapters = make(map[t.TenantID]*adapter) if a.maxResults <= 0 { a.maxResults = defaultMaxResults } if a.maxMessageResults <= 0 { a.maxMessageResults = defaultMaxMessageResults } // This just initializes the driver but does not open the network connection. a.db, a.dbName, err = openMySQLDB(config, a.dsn, a.dbName) if err != nil { return err } // Actually opening the network connection. err = a.db.Ping() if err == nil { if config.SqlTimeout > 0 { a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second // We allocate txTimeoutMultiplier times sqlTimeout for transactions. a.txTimeout = time.Duration(float64(config.SqlTimeout)*txTimeoutMultiplier) * time.Second } if a.tenantRegistryDB != "" && a.tenantRegistryDB != a.dbName { a.registryDB, _, err = openMySQLDB(config, a.dsn, a.tenantRegistryDB) if err == nil { err = a.registryDB.Ping() } if err != nil { return errors.New("mysql adapter failed to open tenant registry database: " + err.Error()) } } if err = ensureUserPrivacySchema(a.db); err != nil { return errors.New("mysql adapter failed to ensure user privacy schema: " + err.Error()) } } return err } // Close closes the underlying database connection func (a *adapter) Close() error { var err error a.tenantMu.Lock() for tenantID, tenantAdp := range a.tenantAdapters { if tenantAdp != nil && tenantAdp.db != nil && tenantAdp.db != a.db { if closeErr := tenantAdp.db.Close(); err == nil { err = closeErr } } delete(a.tenantAdapters, tenantID) } a.tenantMu.Unlock() if a.registryDB != nil { if closeErr := a.registryDB.Close(); err == nil { err = closeErr } a.registryDB = nil } if a.db != nil { if closeErr := a.db.Close(); err == nil { err = closeErr } a.db = nil a.version = -1 } return err } // IsOpen returns true if connection to database has been established. It does not check if // connection is actually live. func (a *adapter) IsOpen() bool { return a.db != nil } // GetDbVersion returns current database version. func (a *adapter) GetDbVersion() (int, error) { if a.version > 0 { return a.version, nil } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var vers int err := a.db.GetContext(ctx, &vers, "SELECT `value` FROM im_kvmeta WHERE `key`='version'") if err != nil { if isMissingDb(err) || isMissingTable(err) || err == sql.ErrNoRows { err = errors.New("Database not initialized") } return -1, err } a.version = vers return vers, nil } func (a *adapter) updateDbVersion(v int) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } a.version = -1 if _, err := a.db.ExecContext(ctx, "UPDATE im_kvmeta SET `value`=? WHERE `key`='version'", v); err != nil { return err } return nil } // CheckDbVersion checks whether the actual DB version matches the expected version of this adapter. func (a *adapter) CheckDbVersion() error { version, err := a.GetDbVersion() if err != nil { return err } if version != adpVersion { return errors.New("Invalid database version " + strconv.Itoa(version) + ". Expected " + strconv.Itoa(adpVersion)) } return nil } // Version returns adapter version. func (*adapter) Version() int { return adpVersion } // DB connection stats object. func (a *adapter) Stats() any { if a.db == nil { return nil } return a.db.Stats() } // GetName returns string that adapter uses to register itself with store. func (a *adapter) GetName() string { return adapterName } // SetMaxResults configures how many results can be returned in a single DB call. func (a *adapter) SetMaxResults(val int) error { if val <= 0 { a.maxResults = defaultMaxResults } else { a.maxResults = val } return nil } // TenantGetByCode resolves a tenant by its public enterprise code. func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var tenant t.Tenant db := a.db if a.registryDB != nil { db = a.registryDB } err := db.GetContext(ctx, &tenant, `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by FROM im_tenant WHERE code=? LIMIT 1`, code) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } return &tenant, nil } // TenantGetByID resolves a tenant registry row by internal ID. func (a *adapter) TenantGetByID(id t.TenantID) (*t.Tenant, error) { if !id.IsValid() { return nil, t.ErrMalformed } return a.tenantByID(id) } func (a *adapter) TenantListActive(limit int) ([]t.Tenant, error) { if limit <= 0 { limit = 1000 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } db := a.db if a.registryDB != nil { db = a.registryDB } var tenants []t.Tenant err := db.SelectContext(ctx, &tenants, `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by FROM im_tenant WHERE state=? ORDER BY id ASC LIMIT ?`, t.TenantStateActive, limit) return tenants, err } func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) { if !a.tenantDBRouting { if a.tenantID != defaultTenantID && a.tenantID != tenantID { return nil, nil, t.ErrMalformed } if a.registryDB != nil { tenant, err := a.tenantByID(tenantID) if err != nil { return nil, nil, err } if tenant.State != t.TenantStateActive { return nil, nil, t.ErrPermissionDenied } } return a, a, nil } tenant, err := a.tenantByID(tenantID) if err != nil { return nil, nil, err } if tenant.State != t.TenantStateActive { return nil, nil, t.ErrPermissionDenied } a.tenantMu.Lock() if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil { a.tenantMu.Unlock() return tenantAdp, tenantAdp, nil } a.tenantMu.Unlock() registryDB := a.registryDB if registryDB == nil { registryDB = a.db } tenantAdp := &adapter{ db: a.db, registryDB: registryDB, dsn: a.dsn, dbName: a.dbName, maxResults: a.maxResults, maxMessageResults: a.maxMessageResults, version: a.version, sqlTimeout: a.sqlTimeout, txTimeout: a.txTimeout, baseConfig: a.baseConfig, tenantDBRouting: false, tenantID: tenantID, // Tenant adapters share the same database. Keep tenant predicates and // explicit tenant_id writes enabled for logical isolation. tenantScopedDB: false, } a.tenantMu.Lock() if existing := a.tenantAdapters[tenantID]; existing != nil { a.tenantMu.Unlock() return existing, existing, nil } a.tenantAdapters[tenantID] = tenantAdp a.tenantMu.Unlock() return tenantAdp, tenantAdp, nil } func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } db := a.db if a.registryDB != nil { db = a.registryDB } var tenant t.Tenant err := db.GetContext(ctx, &tenant, `SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by FROM im_tenant WHERE id=? LIMIT 1`, tenantID) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } if !tenant.IsActive() { return nil, t.ErrPermissionDenied } return &tenant, nil } func createTenantRegistry(tx *sql.Tx, registryDB, _ string) error { if _, err := tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { return err } if _, err := tx.Exec("USE " + quoteIdent(registryDB)); err != nil { return err } if _, err := tx.Exec( `CREATE TABLE IF NOT EXISTS im_tenant( id BIGINT NOT NULL, code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, tenant_desc VARCHAR(256), max_users BIGINT UNSIGNED NOT NULL DEFAULT 0, max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0, max_group_members INT UNSIGNED NOT NULL DEFAULT 0, realname_review_policy TINYINT(1) NOT NULL DEFAULT 0, aliyun_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0, local_moderation_enabled TINYINT(1) NOT NULL DEFAULT 0, message_encryption_enabled TINYINT(1) NOT NULL DEFAULT 0, default_policy_group_id BIGINT, security_policy_version INT UNSIGNED NOT NULL DEFAULT 0, global_friend_enabled TINYINT(1) NOT NULL DEFAULT 1, message_curfew_enabled TINYINT(1) NOT NULL DEFAULT 0, state SMALLINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_by BIGINT NOT NULL, PRIMARY KEY(id), UNIQUE INDEX uk_im_tenant_code(code) )`); err != nil { return err } _, err := tx.Exec( `INSERT INTO im_tenant(id,code,name,tenant_desc,state,created_by,updated_by) VALUES(1,'default','Default Tenant',NULL,?,0,0) ON DUPLICATE KEY UPDATE state=VALUES(state), updated_by=VALUES(updated_by)`, t.TenantStateActive) return err } // CreateDb is intentionally disabled for this deployment. // // Database creation, table creation, schema migration, indexes and seed data // are handled by reviewed SQL files and the release process. The service and // local database tools must not create or mutate schema implicitly. func (a *adapter) CreateDb(reset bool) error { return errors.New("mysql schema auto-creation is disabled; apply reviewed SQL manually before starting the service") } // UpgradeDb is intentionally schema-read-only. // // Automatic ALTER/CREATE upgrade steps are disabled. If the database version // differs from the adapter version, prepare and apply a reviewed SQL migration, // then update im_kvmeta.version as part of that migration. func (a *adapter) UpgradeDb() error { if _, err := a.GetDbVersion(); err != nil { return err } if a.version == adpVersion { return nil } return errors.New("mysql schema auto-upgrade is disabled; apply reviewed SQL manually to upgrade from version " + strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion)) } func (a *adapter) ensureMessageTopicSeqIndex() error { const indexName = "messages_topic_seqid" var count int if err := a.db.QueryRow( `SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='im_messages' AND index_name=?`, indexName).Scan(&count); err != nil { return err } if count > 0 { return nil } _, err := a.db.Exec("ALTER TABLE im_messages ADD UNIQUE INDEX messages_topic_seqid(topic, seqid)") return err } func (a *adapter) ensureTopicNameUniqueIndex() error { ctx := context.Background() conn, err := a.db.Connx(ctx) if err != nil { return err } defer conn.Close() tenantScoped, err := identityColumnExists(ctx, conn, "im_topics", "tenant_id") if err != nil { return err } var count int if tenantScoped { if err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='im_topics' AND index_name='uk_topics_tenant_name' AND non_unique=0`, ).Scan(&count); err != nil { return err } if count > 0 { return nil } if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS keep_t JOIN ( SELECT tenant_id,name,MIN(id) AS keep_id, MAX(seqid) AS seqid,MAX(delid) AS delid, MAX(COALESCE(touchedat,'1000-01-01')) AS touchedat, MAX(updatedat) AS updatedat FROM im_topics GROUP BY tenant_id,name HAVING COUNT(*)>1 ) AS dup ON dup.keep_id=keep_t.id SET keep_t.seqid=GREATEST(keep_t.seqid,dup.seqid), keep_t.delid=GREATEST(keep_t.delid,dup.delid), keep_t.touchedat=NULLIF(GREATEST(COALESCE(keep_t.touchedat,'1000-01-01'),dup.touchedat),'1000-01-01'), keep_t.updatedat=GREATEST(keep_t.updatedat,dup.updatedat)`); err != nil { return err } if _, err = conn.ExecContext(ctx, `DELETE t FROM im_topics AS t JOIN ( SELECT tenant_id,name,MIN(id) AS keep_id FROM im_topics GROUP BY tenant_id,name HAVING COUNT(*)>1 ) AS dup ON dup.tenant_id=t.tenant_id AND dup.name=t.name AND t.id<>dup.keep_id`); err != nil { return err } _, err = conn.ExecContext(ctx, "ALTER TABLE im_topics ADD UNIQUE INDEX uk_topics_tenant_name(tenant_id, name)") if isDupe(err) { return nil } return err } if err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='im_topics' AND index_name='topics_name' AND non_unique=0`, ).Scan(&count); err != nil { return err } if count > 0 { return nil } if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS keep_t JOIN ( SELECT name,MIN(id) AS keep_id, MAX(seqid) AS seqid,MAX(delid) AS delid, MAX(COALESCE(touchedat,'1000-01-01')) AS touchedat, MAX(updatedat) AS updatedat FROM im_topics GROUP BY name HAVING COUNT(*)>1 ) AS dup ON dup.keep_id=keep_t.id SET keep_t.seqid=GREATEST(keep_t.seqid,dup.seqid), keep_t.delid=GREATEST(keep_t.delid,dup.delid), keep_t.touchedat=NULLIF(GREATEST(COALESCE(keep_t.touchedat,'1000-01-01'),dup.touchedat),'1000-01-01'), keep_t.updatedat=GREATEST(keep_t.updatedat,dup.updatedat)`); err != nil { return err } if _, err = conn.ExecContext(ctx, `DELETE t FROM im_topics AS t JOIN ( SELECT name,MIN(id) AS keep_id FROM im_topics GROUP BY name HAVING COUNT(*)>1 ) AS dup ON dup.name=t.name AND t.id<>dup.keep_id`); err != nil { return err } if _, err = conn.ExecContext(ctx, "ALTER TABLE im_topics ADD UNIQUE INDEX topics_name(name)"); err != nil { if isDupe(err) { return nil } return err } return nil } func (a *adapter) ensureTopicSeqMatchesMessages() error { ctx := context.Background() conn, err := a.db.Connx(ctx) if err != nil { return err } defer conn.Close() topicsScoped, err := identityColumnExists(ctx, conn, "im_topics", "tenant_id") if err != nil { return err } messagesScoped, err := identityColumnExists(ctx, conn, "im_messages", "tenant_id") if err != nil { return err } if topicsScoped && messagesScoped { if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS t JOIN (SELECT tenant_id,topic,MAX(seqid) AS max_seq FROM im_messages GROUP BY tenant_id,topic) AS m ON m.tenant_id=t.tenant_id AND m.topic COLLATE utf8mb4_unicode_ci=t.name SET t.seqid=GREATEST(t.seqid,m.max_seq) WHERE t.seqid0`); err != nil { return 0, true, err } if _, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences SET next_value=GREATEST(next_value,(SELECT GREATEST(COALESCE(MAX(account_no),999),999) FROM im_users WHERE account_no>0)) WHERE id=1`); err != nil { return 0, true, err } result, err := tx.ExecContext(ctx, `UPDATE im_user_account_sequences SET next_value=LAST_INSERT_ID(next_value+1) WHERE id=1`) if err != nil { return 0, true, err } if affected, affectedErr := result.RowsAffected(); affectedErr != nil { return 0, true, affectedErr } else if affected != 1 { return 0, true, errors.New("mysql: user account sequence is not initialized") } var next uint64 if err = tx.GetContext(ctx, &next, "SELECT LAST_INSERT_ID()"); err != nil { return 0, true, err } if next > uint64(^uint32(0)) { return 0, true, errors.New("mysql: user account number exhausted") } return uint32(next), true, nil } // TenantAuthAddRecord adds the user's Basic authentication record. func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires) } var exp *time.Time if !expires.IsZero() { exp = &expires } var username string ctx, cancel := a.getContext() if cancel != nil { defer cancel() } if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE tenant_id=? AND id=?", tenantID, store.DecodeUid(uid)); err != nil { if err == sql.ErrNoRows { return t.ErrNotFound } return err } authID, err := nextSnowflakeID() if err != nil { return err } _, err = a.db.ExecContext(ctx, "INSERT INTO im_auth(tenant_id,id,userid,authLvl,secret,expires) VALUES(?,?,?,?,?,?)", tenantID, authID, store.DecodeUid(uid), authLvl, secret, exp) if isIDOutOfRange(err) { _, err = a.db.ExecContext(ctx, "INSERT INTO im_auth(tenant_id,userid,authLvl,secret,expires) VALUES(?,?,?,?,?)", tenantID, store.DecodeUid(uid), authLvl, secret, exp) } if isDupe(err) { return t.ErrDuplicate } return err } // TenantAuthDeleteRecord deletes the Basic authentication record. func (a *adapter) TenantAuthDeleteRecord(tenantID t.TenantID, uid t.Uid) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantAuthDeleteRecord(tenantID, uid) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err = a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE tenant_id=? AND userid=?", tenantID, store.DecodeUid(uid)) return err } // TenantAuthUpdateRecord updates the Basic authentication record and optional username. func (a *adapter) TenantAuthUpdateRecord(tenantID t.TenantID, uid t.Uid, username string, authLvl auth.Level, secret []byte, expires time.Time) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires) } params := []string{"authLvl=?"} args := []any{authLvl} if len(secret) > 0 { params = append(params, "secret=?") args = append(args, secret) } if !expires.IsZero() { params = append(params, "expires=?") args = append(args, expires) } decodedUID := store.DecodeUid(uid) 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 username != "" { if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE tenant_id=? AND id=?", username, t.TimeNow(), tenantID, decodedUID); err != nil { if isDupe(err) { return t.ErrDuplicate } return err } } args = append(args, tenantID, decodedUID) resp, err := tx.ExecContext(ctx, "UPDATE im_auth SET "+strings.Join(params, ",")+" WHERE tenant_id=? AND userid=?", args...) if err != nil { return err } if count, _ := resp.RowsAffected(); count <= 0 { return t.ErrNotFound } return tx.Commit() } // TenantAuthGetRecord retrieves a user's Basic authentication record. func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid) (string, auth.Level, []byte, time.Time, error) { var expires time.Time tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return "", auth.LevelNone, nil, expires, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantAuthGetRecord(tenantID, uid) } var record struct { Username string Authlvl auth.Level Secret []byte Expires *time.Time } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } err = a.db.GetContext(ctx, &record, `SELECT u.username,a.secret,a.expires,a.authlvl FROM im_auth AS a JOIN im_users AS u ON u.tenant_id=a.tenant_id AND u.id=a.userid WHERE a.tenant_id=? AND a.userid=?`, tenantID, store.DecodeUid(uid)) if err == sql.ErrNoRows { return "", auth.LevelNone, nil, expires, t.ErrNotFound } if err != nil { return "", auth.LevelNone, nil, expires, err } if record.Expires != nil { expires = *record.Expires } return record.Username, record.Authlvl, record.Secret, expires, nil } // TenantAuthGetByLogin retrieves a Basic authentication record by tenant-local login. func (a *adapter) TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid, auth.Level, []byte, time.Time, error) { var expires time.Time tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantAuthGetByLogin(tenantID, login) } var record struct { Userid int64 Authlvl auth.Level Secret []byte Expires *time.Time } lookup := login if len(login) == 11 && asciiDigits(login) { lookup = "+86" + login } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } err = a.db.GetContext(ctx, &record, `SELECT a.userid,a.secret,a.expires,a.authlvl FROM im_users AS u JOIN im_auth AS a ON a.tenant_id=u.tenant_id AND a.userid=u.id WHERE u.tenant_id=? AND (u.username=? OR (u.phone IS NOT NULL AND u.phone=?)) ORDER BY (u.username=?) DESC LIMIT 1`, tenantID, login, lookup, login) if err == sql.ErrNoRows { return t.ZeroUid, auth.LevelNone, nil, expires, nil } if err != nil { return t.ZeroUid, auth.LevelNone, nil, expires, err } if record.Expires != nil { expires = *record.Expires } return store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil } // Add user's authentication record func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { var exp *time.Time if !expires.IsZero() { exp = &expires } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var storedUsername string if err := a.db.GetContext(ctx, &storedUsername, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil { if err == sql.ErrNoRows { return t.ErrNotFound } return err } else if unique != storedUsername { return t.ErrMalformed } authID, err := nextSnowflakeID() if err != nil { return err } if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(id,userid,authLvl,secret,expires) VALUES(?,?,?,?,?)", authID, store.DecodeUid(uid), authLvl, secret, exp); isIDOutOfRange(err) { _, err = a.db.ExecContext(ctx, "INSERT INTO im_auth(userid,authLvl,secret,expires) VALUES(?,?,?,?)", store.DecodeUid(uid), authLvl, secret, exp) if err != nil && isDupe(err) { return t.ErrDuplicate } return err } else if err != nil { if isDupe(err) { return t.ErrDuplicate } return err } return nil } // AuthDelScheme deletes an existing authentication scheme for the user. func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user)) return err } // AuthDelAllRecords deletes all authentication records for the user. func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } res, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user)) if err != nil { return 0, err } count, _ := res.RowsAffected() return int(count), nil } // Update user's authentication unique, secret, auth level. func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { params := []string{"authLvl=?"} args := []any{authLvl} if len(secret) > 0 { params = append(params, "secret=?") args = append(args, secret) } if !expires.IsZero() { params = append(params, "expires=?") args = append(args, expires) } decodedUID := store.DecodeUid(uid) args = append(args, decodedUID) 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 unique != "" { if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE id=?", unique, t.TimeNow(), decodedUID); err != nil { if isDupe(err) { return t.ErrDuplicate } return err } } query := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=?" resp, err := tx.ExecContext(ctx, query, args...) if isDupe(err) { return t.ErrDuplicate } if err != nil { return err } if count, _ := resp.RowsAffected(); count <= 0 { var exists int if err = tx.GetContext(ctx, &exists, "SELECT COUNT(*) FROM im_auth WHERE userid=?", decodedUID); err != nil { return err } if exists == 0 { return t.ErrNotFound } } return tx.Commit() } // Retrieve user's authentication record func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) { var expires time.Time var record struct { Username string Authlvl auth.Level Secret []byte Expires *time.Time } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } if err := a.db.GetContext(ctx, &record, `SELECT u.username,a.secret,a.expires,a.authlvl FROM im_auth AS a JOIN im_users AS u ON u.id=a.userid WHERE a.userid=?`, store.DecodeUid(uid)); err != nil { if err == sql.ErrNoRows { // Nothing found - use standard error. err = t.ErrNotFound } return "", 0, nil, expires, err } if record.Expires != nil { expires = *record.Expires } return record.Username, record.Authlvl, record.Secret, expires, nil } // Retrieve user's authentication record func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) { var expires time.Time var record struct { Userid int64 Authlvl auth.Level Secret []byte Expires *time.Time } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } lookup := unique if len(unique) == 11 && asciiDigits(unique) { lookup = "+86" + unique } if err := a.db.GetContext(ctx, &record, `SELECT a.userid,a.secret,a.expires,a.authlvl FROM im_users AS u JOIN im_auth AS a ON a.userid=u.id WHERE u.username=? OR (u.phone IS NOT NULL AND u.phone=?) ORDER BY (u.username=?) DESC LIMIT 1`, unique, lookup, unique); err != nil { if err == sql.ErrNoRows { // Nothing found - clear the error err = nil } return t.ZeroUid, 0, nil, expires, err } if record.Expires != nil { expires = *record.Expires } return store.EncodeUid(record.Userid), record.Authlvl, record.Secret, expires, nil } // UserGet fetches a single user by user id. If user is not found it returns (nil, nil) func (a *adapter) UserGet(uid t.Uid) (*t.User, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var row profileUserRow err := a.db.GetContext(ctx, &row, `SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent, COALESCE(u.phone,'') AS phone,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id=? AND u.state!=?`, store.DecodeUid(uid), t.StateDeleted) if err == nil { user := profileUserFromRow(row) user.SetUid(uid) return &user, nil } if err == sql.ErrNoRows { // Clear the error if user does not exist or marked as soft-deleted. return nil, nil } return nil, err } func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) { uids := make([]any, len(ids)) for i, id := range ids { if id.IsZero() { continue } uids[i] = store.DecodeUid(id) } users := []t.User{} ctx, cancel := a.getContext() if cancel != nil { defer cancel() } q, uids, _ := sqlx.In(`SELECT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent, COALESCE(u.phone,'') AS phone,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id WHERE u.id IN (?) AND u.state!=?`, uids, t.StateDeleted) rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var row profileUserRow if err = rows.StructScan(&row); err != nil { users = nil break } user := profileUserFromRow(row) users = append(users, user) } if err == nil { err = rows.Err() } return users, err } // UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted. // TODO: report when the user is not found. func (a *adapter) UserDelete(uid t.Uid, hard bool) error { query := "SELECT name FROM im_topics WHERE owner=?" args := []any{store.DecodeUid(uid)} // In case of hard delete, delete all topics, even those which were // soft-deleted previsously. if !hard { query += " AND state!=?" args = append(args, t.StateDeleted) } // Get a list of topic names owned by the user (as 'grp' and 'chn'). ownTopics, err := a.topicNamesForUser(query, true, args...) if err != nil { return err } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() now := t.TimeNow() decoded_uid := store.DecodeUid(uid) if hard { // Delete user's devices // t.ErrNotFound = user has no devices. if err = deviceDelete(tx, uid, ""); err != nil && err != t.ErrNotFound { return err } // Delete user's subscriptions in all topics. if err = subsDelForUser(tx, decoded_uid, true); err != nil { return err } // Delete records of messages soft-deleted for the user in all topics. if _, err = tx.Exec("DELETE FROM im_dellog WHERE deletedfor=?", decoded_uid); err != nil { return err } // Can't delete user's messages in all topics because we cannot notify topics of such deletion. // Just leave the messages there marked as sent by "not found" user. // Delete topics where the user is the owner. if len(ownTopics) > 0 { // First delete all messages in those topics. if _, err = tx.Exec("DELETE im_dellog FROM im_dellog JOIN im_topics ON im_topics.name=im_dellog.topic WHERE im_topics.owner=?", decoded_uid); err != nil { return err } // Deletion of messages will cascade to filemsglinks and so to fileuploads. if _, err = tx.Exec("DELETE im_messages FROM im_messages JOIN im_topics ON im_topics.name=im_messages.topic WHERE im_topics.owner=?", decoded_uid); err != nil { return err } // Delete subscriptions for all users where the user is the owner of the topic. sql, args, _ := sqlx.In("DELETE FROM im_subscriptions AS s WHERE topic IN (?)", ownTopics) if _, err = tx.Exec(tx.Rebind(sql), args); err != nil { return err } // Delete topic tags. if _, err = tx.Exec("DELETE tt FROM im_topictags AS tt JOIN im_topics AS t ON t.name=tt.topic WHERE t.owner=?", decoded_uid); err != nil { return err } // And finally delete the topics. if _, err = tx.Exec("DELETE FROM im_topics WHERE owner=?", decoded_uid); err != nil { return err } } // Delete user's authentication records. if _, err = tx.Exec("DELETE FROM im_auth WHERE userid=?", decoded_uid); err != nil { return err } // Delete all credentials. if err = credDel(tx, uid, "", ""); err != nil && err != t.ErrNotFound { return err } if _, err = tx.Exec("DELETE FROM im_users WHERE id=?", decoded_uid); err != nil { return err } } else { // Disable all user's subscriptions. That includes p2p subscriptions. No need to delete them. if err = subsDelForUser(tx, decoded_uid, false); err != nil { return err } if len(ownTopics) > 0 { // Disable all subscriptions to topics where the user is the owner. sql, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, ownTopics) if _, err = tx.Exec(tx.Rebind(sql), args...); err != nil { return err } } // Disable group topics where the user is the owner. if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE owner=?", now, now, t.StateDeleted, now, decoded_uid); err != nil { return err } // Disable p2p topics with the user (p2p topic's owner is 0). if _, err = tx.Exec("UPDATE im_topics AS t JOIN im_subscriptions AS s ON t.name=s.topic "+ "SET t.updatedat=?,t.touchedat=?,t.state=?,t.stateat=? "+ "WHERE t.owner=0 AND s.userid=? AND t.name LIKE 'p2p%'", now, now, t.StateDeleted, now, decoded_uid); err != nil { return err } // Disable the other user's subscription to a disabled p2p topic. if _, err = tx.Exec("UPDATE im_subscriptions AS s_one JOIN im_subscriptions AS s_two "+ "ON s_one.topic=s_two.topic "+ "SET s_two.updatedat=?, s_two.deletedat=? WHERE s_one.userid=? AND s_one.topic LIKE 'p2p%'", now, now, decoded_uid); err != nil { return err } // Finally disable user. if _, err = tx.Exec("UPDATE im_users SET updatedat=?,state=?,stateat=? WHERE id=?", now, t.StateDeleted, now, decoded_uid); err != nil { return err } } return tx.Commit() } // topicStateForUser is called by UserUpdate when the update contains state change. // Soft-deleted topics remain soft-deleted. func (a *adapter) topicStateForUser(tx *sqlx.Tx, decoded_uid int64, now time.Time, update any) error { var err error state, ok := update.(t.ObjState) if !ok { return t.ErrMalformed } if now.IsZero() { now = t.TimeNow() } // Change state of all topics where the user is the owner. if _, err = tx.Exec("UPDATE im_topics SET state=?, stateat=? WHERE owner=? AND state!=?", state, now, decoded_uid, t.StateDeleted); err != nil { return err } // Change state of p2p topics with the user (p2p topic's owner is 0) if _, err = tx.Exec("UPDATE im_topics JOIN im_subscriptions ON im_topics.name=im_subscriptions.topic "+ "SET im_topics.state=?, im_topics.stateat=? WHERE im_topics.owner=0 AND im_subscriptions.userid=? AND im_topics.state!=?", state, now, decoded_uid, t.StateDeleted); err != nil { return err } // Subscriptions don't need to be updated: // subscriptions of a disabled user are not disabled and still can be manipulated. return nil } // UserUpdate updates user object. func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() update = sanitizeUserUpdate(update) cols, args := common.UpdateByMap(update) if len(cols) == 0 { return tx.Commit() } decoded_uid := store.DecodeUid(uid) args = append(args, decoded_uid) _, err = tx.Exec("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...) if err != nil { return err } if state, ok := update["State"]; ok { now, _ := update["StateAt"].(time.Time) err = a.topicStateForUser(tx, decoded_uid, now, state) if err != nil { return err } } return tx.Commit() } // UserUpdateTags is retained for adapter compatibility; user tags are disabled. func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) { return nil, nil } // UserGetByCred returns user ID for the given validated credential. func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var decoded_uid int64 var err error if method == "tel" { err = a.db.GetContext(ctx, &decoded_uid, "SELECT id FROM im_users WHERE phone=? AND state!=?", value, t.StateDeleted) } else { err = a.db.GetContext(ctx, &decoded_uid, "SELECT userid FROM im_credentials WHERE synthetic=?", method+":"+value) } if err == nil { return store.EncodeUid(decoded_uid), nil } if err == sql.ErrNoRows { // Clear the error if user does not exist return t.ZeroUid, nil } return t.ZeroUid, err } // TenantUserGetByCred returns a user ID for a validated credential in one tenant. func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error) { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ZeroUid, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantUserGetByCred(tenantID, method, value) } if method == "tel" { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var decodedUID int64 err = a.db.GetContext(ctx, &decodedUID, "SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", tenantID, value, t.StateDeleted) if err == sql.ErrNoRows { return t.ZeroUid, nil } if err != nil { return t.ZeroUid, err } return store.EncodeUid(decodedUID), nil } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var decodedUID int64 err = a.db.GetContext(ctx, &decodedUID, "SELECT userid FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, method+":"+value) if err == sql.ErrNoRows { return t.ZeroUid, nil } if err != nil { return t.ZeroUid, err } return store.EncodeUid(decodedUID), nil } // UserUnreadCount returns the total number of unread messages in all topics with // the R permission. If read fails, the counts are still returned with the original // user IDs but with the unread count undefined and non-nil error. // UserUnreadCount does not count unread messages in channels although it should. func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) { uids := make([]any, len(ids)) counts := make(map[t.Uid]int, len(ids)) for i, id := range ids { uids[i] = store.DecodeUid(id) // Ensure all original uids are always present. counts[id] = 0 } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } // FIXME: support channels (for channels subscriptions.topic != topics.name). q, args, _ := sqlx.In("SELECT s.userid, SUM(t.seqid)-SUM(s.readseqid) AS unreadcount FROM im_topics AS t, im_subscriptions AS s "+ "WHERE s.userid IN (?) AND t.name=s.topic AND s.deletedat IS NULL AND t.state!=? AND "+ "INSTR(s.modewant, 'R')>0 AND INSTR(s.modegiven, 'R')>0 GROUP BY s.userid", uids, int(t.StateDeleted)) rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), args...) if err != nil { return counts, err } defer rows.Close() var userId int64 var unreadCount int for rows.Next() { if err = rows.Scan(&userId, &unreadCount); err != nil { break } counts[store.EncodeUid(userId)] = unreadCount } if err == nil { err = rows.Err() } return counts, err } // UserGetUnvalidated returns a list of uids which have never logged in, have no // validated credentials and haven't been updated since lastUpdatedBefore. func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) { var uids []t.Uid ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, "SELECT u.id, IFNULL(SUM(c.done),0) AS total FROM im_users AS u "+ "LEFT JOIN im_credentials AS c ON u.id=c.userid WHERE u.lastseen IS NULL AND u.updatedat 0 && opts.Limit < a.maxResults { limit = opts.Limit } else { limit = a.maxResults } } else { ims = *opts.IfModifiedSince } } else { limit = a.maxResults } q += " ORDER BY topic ASC, deletedat IS NULL DESC, updatedat DESC, id DESC" if limit > 0 { q += " LIMIT ?" args = append(args, limit) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, q, args...) if err != nil { return nil, err } // Must close rows manually as we will be reusing it. // Fetch subscriptions. Two queries are needed: users table (p2p) and topics table (grp). // Prepare a list of separate subscriptions to users vs topics join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access p2pResolved := make(map[string]bool) topq := make([]any, 0, 16) usrq := make([]any, 0, 16) for rows.Next() { var sub t.Subscription if err = rows.StructScan(&sub); err != nil { break } tname := sub.Topic sub.User = uid.String() if !t.ParseUserId(tname).IsZero() { // Legacy self-user subscriptions are not conversation entries. continue } tcat := t.GetTopicCat(tname) peerID := "" if tcat == t.TopicCatMe || tcat == t.TopicCatFnd { // One of 'me', 'fnd' subscriptions, skip. // Don't skip 'sys' subscription. continue } else if tcat == t.TopicCatP2P { // P2P subscription, find the other user to get user.Public and user.Trusted. uid1, uid2, _ := t.ParseP2P(tname) p2pResolved[tname] = false if uid1 == uid { usrq = append(usrq, store.DecodeUid(uid2)) peerID = uid2.UserId() sub.SetWith(peerID) } else { usrq = append(usrq, store.DecodeUid(uid1)) peerID = uid1.UserId() sub.SetWith(peerID) } } else if tcat == t.TopicCatGrp { // Maybe convert channel name to group topic name. tname = t.ChnToGrp(tname) } if _, exists := join[tname]; exists { continue } // No special handling needed for 'slf', 'sys' subscriptions. topq = append(topq, tname) sub.Private = common.FromJSON(sub.Private) if tcat == t.TopicCatP2P { sub.Private = ensureP2PPrivatePeer(sub.Private, peerID) } join[tname] = sub } if err == nil { err = rows.Err() } rows.Close() if err != nil { return nil, err } var subs []t.Subscription if len(join) == 0 { return subs, nil } // Fetch grp topics and join to subscriptions. if len(topq) > 0 { q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,IFNULL(mutation_version,0) AS mutation_version,public,trusted " + "FROM im_topics WHERE name IN (?)" q, args, _ = sqlx.In(q, topq) if !keepDeleted { // Optionally skip deleted topics. q += " AND state!=?" args = append(args, t.StateDeleted) } if !ims.IsZero() { // Use cache timestamp if provided: get newer entries only. q += " AND touchedat>?" args = append(args, ims) if limit > 0 && limit < len(topq) { // No point in fetching more than the requested limit. q += " ORDER BY touchedat LIMIT ?" args = append(args, limit) } } ctx2, cancel2 := a.getContext() if cancel2 != nil { defer cancel2() } rows, err = a.db.QueryxContext(ctx2, a.db.Rebind(q), args...) if err != nil { return nil, err } var top t.Topic for rows.Next() { if err = rows.StructScan(&top); err != nil { break } sub := join[top.Id] // Check if sub.UpdatedAt needs to be adjusted to earlier or later time. sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt) sub.SetState(top.State) sub.SetTouchedAt(top.TouchedAt) sub.SetSeqId(top.SeqId) sub.Private = ensurePrivateMutationVersion(sub.Private, top.MutationVersion) if t.GetTopicCat(sub.Topic) == t.TopicCatGrp { sub.SetSubCnt(top.SubCnt) sub.SetPublic(common.FromJSON(top.Public)) sub.SetTrusted(common.FromJSON(top.Trusted)) } // Put back the updated value of a subsription, will process further below join[top.Id] = sub } if err == nil { err = rows.Err() } rows.Close() if err != nil { return nil, err } } // Fetch p2p users and join to p2p subscriptions. if len(usrq) > 0 { q = `SELECT u.id,u.updatedat,u.state,u.access,u.lastseen,u.useragent,u.trusted, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at, fs.remark AS friendship_remark FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=u.id AND fs.status=? WHERE u.id IN (?)` q, args, _ = sqlx.In(q, store.DecodeUid(uid), t.RelationshipFriend, usrq) if !keepDeleted { // Optionally skip deleted users. q += " AND u.state!=?" args = append(args, t.StateDeleted) } // Ignoring ims: we need all users to get LastSeen and UserAgent. ctx3, cancel3 := a.getContext() if cancel3 != nil { defer cancel3() } rows, err = a.db.QueryxContext(ctx3, a.db.Rebind(q), args...) if err != nil { return nil, err } for rows.Next() { var usr2 profileUserRow if err = rows.StructScan(&usr2); err != nil { break } joinOn := uid.P2PName(common.EncodeUidString(usr2.ID)) if sub, ok := join[joinOn]; ok { sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.effectiveUpdatedAt()) sub.SetState(usr2.State) sub.SetPublic(usr2.profilePublic()) sub.SetTrusted(common.FromJSON(usr2.Trusted)) sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon) sub.SetLastSeenAndUA(usr2.lastSeen(), usr2.UserAgent) sub.Private = ensureP2PPrivatePeerRemark(sub.Private, common.EncodeUidString(usr2.ID).UserId(), usr2.FriendshipRemark.String) join[joinOn] = sub p2pResolved[joinOn] = true } } if err == nil { err = rows.Err() } rows.Close() if err != nil { return nil, err } } subs = make([]t.Subscription, 0, len(join)) for _, sub := range join { if t.GetTopicCat(sub.Topic) == t.TopicCatP2P && !p2pResolved[sub.Topic] { continue } subs = append(subs, sub) } return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil } // UsersForTopic loads users subscribed to the given topic (not channel readers). // The difference between UsersForTopic vs SubsForTopic is that the former loads user.Public, // the latter does not. func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { tcat := t.GetTopicCat(topic) // Fetch all subscribed users. The number of users is not large. q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, s.readseqid,s.modewant,s.modegiven,u.trusted,u.lastseen,u.useragent,s.private, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,pu.location AS profile_avatar_url,p.signature AS profile_signature, p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id LEFT JOIN im_user_profiles AS p ON p.user_id=s.userid LEFT JOIN im_public_uploads AS pu ON pu.file_id=p.avatar_file_id WHERE s.topic=?` args := []any{topic} if !keepDeleted { // Filter out rows with users deleted q += " AND u.state!=?" args = append(args, t.StateDeleted) // For p2p topics we must load all subscriptions including deleted. // Otherwise it will be impossible to swipe Public values. if tcat != t.TopicCatP2P { // Filter out deleted subscriptions. q += " AND s.deletedat IS NULL" } } limit := a.maxResults var oneUser t.Uid if opts != nil { // Ignore IfModifiedSince: loading all entries because a topic cannot have too many subscribers. // Those unmodified will be stripped of Public & Private. if !opts.User.IsZero() { // For p2p topics we have to fetch both users otherwise public cannot be swapped. if tcat != t.TopicCatP2P { q += " AND s.userid=?" args = append(args, store.DecodeUid(opts.User)) } oneUser = opts.User } if opts.Limit > 0 && opts.Limit < limit { limit = opts.Limit } } q += " LIMIT ?" args = append(args, limit) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() // Fetch subscriptions. var sub t.Subscription var subs []t.Subscription var lastSeen sql.NullTime var userAgent string var trusted any var profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString for rows.Next() { if err = rows.Scan( &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven, &trusted, &lastSeen, &userAgent, &sub.Private, &profileNickname, &profileAvatarFileID, &profileAvatarURL, &profileSignature, &profileGender, &profileRegion, &profilePublicAccount); err != nil { break } sub.User = common.EncodeUidString(sub.User).String() sub.Private = common.FromJSON(sub.Private) sub.SetPublic(profilePublicFromFieldsWithAvatarURL(profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount)) sub.SetTrusted(common.FromJSON(trusted)) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) subs = append(subs, sub) } if err == nil { err = rows.Err() } if err == nil && tcat == t.TopicCatP2P && len(subs) > 0 { // Swap public & lastSeen values of P2P topics as expected. if len(subs) == 1 { // The other user is deleted, nothing we can do. subs[0].SetPublic(nil) subs[0].SetTrusted(nil) subs[0].SetLastSeenAndUA(nil, "") } else { tmp := subs[0].GetPublic() subs[0].SetPublic(subs[1].GetPublic()) subs[1].SetPublic(tmp) tmp = subs[0].GetTrusted() subs[0].SetTrusted(subs[1].GetTrusted()) subs[1].SetTrusted(tmp) lastSeen := subs[0].GetLastSeen() userAgent = subs[0].GetUserAgent() subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent()) subs[1].SetLastSeenAndUA(lastSeen, userAgent) } // Remove deleted and unneeded subscriptions if !keepDeleted || !oneUser.IsZero() { var xsubs []t.Subscription for i := range subs { if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) { continue } xsubs = append(xsubs, subs[i]) } subs = xsubs } } return subs, err } // topicNamesForUser reads a slice of strings using provided query. // if includeChan is true, the query is expected to add channel names as well as group topic names. func (a *adapter) topicNamesForUser(sqlQuery string, includeChan bool, args ...any) ([]string, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, sqlQuery, args...) if err != nil { return nil, err } defer rows.Close() var names []string for rows.Next() { var name string if err = rows.Scan(&name); err != nil { break } names = append(names, name) // If the name is a group topic, also add the channel name if requested. if includeChan { if channel := t.GrpToChn(name); channel != "" { names = append(names, channel) } } } if err == nil { err = rows.Err() } return names, err } // OwnTopics loads a slice of topic names where the user is the owner. func (a *adapter) OwnTopics(uid t.Uid) ([]string, error) { return a.topicNamesForUser("SELECT name FROM im_topics WHERE owner=? AND state!=?", false, store.DecodeUid(uid), t.StateDeleted) } // ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled. func (a *adapter) ChannelsForUser(uid t.Uid) ([]string, error) { return a.topicNamesForUser("SELECT topic FROM im_subscriptions WHERE userid=? AND topic LIKE 'chn%' "+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", false, store.DecodeUid(uid)) } // TopicShare adds subscriptions to a topic and increments the topic's subcnt. func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() for _, sub := range shares { err = createSubscription(tx, sub, true) if err != nil { return err } } if topic != "" { // Update topic's subscription count. if _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt+? WHERE name=?", len(shares), topic); err != nil { return err } } return tx.Commit() } // TopicDelete deletes topic, subscriptions, messages. func (a *adapter) TopicDelete(topic string, isChan, hard bool) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() // If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names. args := []any{topic} if isChan { args = append(args, t.GrpToChn(topic)) } if hard { // Delete subscriptions. If this is a channel, delete both group subscriptions and channel subscriptions. q, args, _ := sqlx.In("DELETE FROM im_subscriptions WHERE topic IN (?)", args) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } if err = messageDeleteList(tx, topic, nil); err != nil { return err } if _, err = tx.Exec("DELETE FROM im_topictags WHERE topic=?", topic); err != nil { return err } if _, err = tx.Exec("DELETE FROM im_topics WHERE name=?", topic); err != nil { return err } } else { now := t.TimeNow() q, args, _ := sqlx.In("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args) if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=?,stateat=? WHERE name=?", now, now, t.StateDeleted, now, topic); err != nil { return err } } return tx.Commit() } // TopicUpdateOnMessage updates topic's seqid and touchedat when a new message is posted. func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "UPDATE im_topics SET seqid=?,touchedat=? WHERE name=?", msg.SeqId, msg.CreatedAt, topic) return err } // TopicUpdateSubCnt updates subscriber count denormalized in topic. func (a *adapter) TopicUpdateSubCnt(topic string) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "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 } // TopicUpdate updates topic's fields given in the update map. // If update contains UpdatedAt but not TouchedAt, TouchedAt is set to Updated func (a *adapter) TopicUpdate(topic string, update map[string]any) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil { update["TouchedAt"] = u } cols, args := common.UpdateByMap(update) args = append(args, topic) _, err = tx.Exec("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE name=?", args...) if err != nil { return err } // Tags are also stored in a separate table if tags := common.ExtractTags(update); tags != nil { // First delete all user tags _, err = tx.Exec("DELETE FROM im_topictags WHERE topic=?", topic) if err != nil { return err } // Now insert new tags err = addTags(tx, "im_topictags", "topic", topic, tags, false) if err != nil { return err } } return tx.Commit() } func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "UPDATE im_topics SET owner=? WHERE name=?", store.DecodeUid(newOwner), topic) return err } // Get a subscription of a user to a topic. func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } query := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=? AND userid=?` if !keepDeleted { query += " AND deletedat IS NULL" } var sub t.Subscription err := a.db.GetContext(ctx, &sub, query, topic, store.DecodeUid(user)) if err != nil { if err == sql.ErrNoRows { // Nothing found - clear the error err = nil } return nil, err } sub.User = user.String() sub.Private = common.FromJSON(sub.Private) return &sub, nil } // SubsForUser loads all user's subscriptions. Does NOT load Public or Private values and does // not load deleted subscriptions. func (a *adapter) SubsForUser(forUser t.Uid) ([]t.Subscription, error) { q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven FROM im_subscriptions WHERE userid=? AND deletedat IS NULL` args := []any{store.DecodeUid(forUser)} ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var subs []t.Subscription var sub t.Subscription for rows.Next() { if err = rows.StructScan(&sub); err != nil { break } sub.User = forUser.String() subs = append(subs, sub) } if err == nil { err = rows.Err() } return subs, err } // SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value and does not load channel readers. // The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted, // the latter does not. func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=?` args := []any{topic} if !keepDeleted { // Filter out deleted rows. q += " AND deletedat IS NULL" } limit := 0 if opts != nil { // Ignore IfModifiedSince - we must return all entries // Those unmodified will be stripped of Public & Private. if !opts.User.IsZero() { q += " AND userid=?" args = append(args, store.DecodeUid(opts.User)) } limit = a.maxResults if opts.Limit > 0 && opts.Limit < limit { limit = opts.Limit } } if limit > 0 { q += " LIMIT ?" args = append(args, limit) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var subs []t.Subscription var sub t.Subscription for rows.Next() { if err = rows.StructScan(&sub); err != nil { break } sub.User = common.EncodeUidString(sub.User).String() sub.Private = common.FromJSON(sub.Private) subs = append(subs, sub) } if err == nil { err = rows.Err() } return subs, err } // SubsUpdate updates one or multiple subscriptions to a topic. func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() cols, args := common.UpdateByMap(update) q := "UPDATE im_subscriptions SET " + strings.Join(cols, ",") + " WHERE topic=?" args = append(args, topic) if !user.IsZero() { // Update just one topic subscription q += " AND userid=?" args = append(args, store.DecodeUid(user)) } if _, err = tx.Exec(q, args...); err != nil { return err } return tx.Commit() } // SubsDelete marks at most one subscription as deleted (soft-deleting). func (a *adapter) SubsDelete(topic string, user t.Uid) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() decoded_id := store.DecodeUid(user) now := t.TimeNow() // Mark subscription as deleted. res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decoded_id) if err != nil { return err } affected, err := res.RowsAffected() if err == nil && affected == 0 { // ensure tx.Rollback() above is ran err = t.ErrNotFound return err } // Channel readers cannot delete messages. if !t.IsChannel(topic) { // Remove records of messages soft-deleted by this user. _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=? AND deletedfor=?", topic, decoded_id) if err != nil { return err } } if t.GetTopicCat(topic) == t.TopicCatGrp { // Decrement topic subscription count (only one subscription is deleted). _, err = tx.Exec("UPDATE im_topics SET subcnt=subcnt-1 WHERE name=?", t.ChnToGrp(topic)) if err != nil { return err } } return tx.Commit() } // subsDelForUser marks user's subscriptions as deleted. func subsDelForUser(tx *sqlx.Tx, decoded_uid int64, hard bool) error { // Decrement subscription count for all topics the user is subscribed to. rows, err := tx.Query("SELECT topic FROM im_subscriptions WHERE userid=? AND deletedat IS NULL", decoded_uid) if err != nil { return err } var topics []any for rows.Next() { var name string if err = rows.Scan(&name); err != nil { break } if t.IsChannel(name) { // Convert channel name to group name. name = t.ChnToGrp(name) } topics = append(topics, name) } if err == nil { err = rows.Err() } rows.Close() if err != nil { return err } if len(topics) > 0 { sql, args, err := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE name IN (?)", topics) _, err = tx.Exec(tx.Rebind(sql), args...) if err != nil { return err } } if hard { _, err = tx.Exec("DELETE FROM im_subscriptions WHERE userid=?", decoded_uid) } else { now := t.TimeNow() _, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE userid=? AND deletedat IS NULL", now, now, decoded_uid) } return err } // SubsDelForUser marks user's subscriptions as deleted. func (a *adapter) SubsDelForUser(user t.Uid, hard bool) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() if err = subsDelForUser(tx, store.DecodeUid(user), hard); err != nil { return err } return tx.Commit() } // Find returns group topics which match the requested discovery tags. func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) { index := make(map[string]struct{}) allReq := t.FlattenDoubleSlice(req) allTags := append(append([]string{}, allReq...), opt...) if len(allTags) == 0 { return nil, nil } var args []any stateConstraint := "" if activeOnly { args = append(args, t.StateOK) stateConstraint = "t.state=? AND " } for _, tag := range allTags { args = append(args, tag) index[tag] = struct{}{} } var matcher string if promoPrefix != "" { // The max number of tags is 16. Using 20 to make sure one prefix match is greater than all non-prefix matches. matcher = "SUM(CASE WHEN LOCATE('" + promoPrefix + "', tg.tag)=1 THEN 20 ELSE 1 END)" } else { matcher = "COUNT(*)" } query := "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags," + matcher + " AS matches " + "FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " + "WHERE " + stateConstraint + "tg.tag IN (?" + strings.Repeat(",?", len(allTags)-1) + ") " + "GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access,t.subcnt,t.public,t.trusted,t.tags " if len(allReq) > 0 { q, a := common.DisjunctionSql(req, "tg.tag") query += q args = append(args, a...) } query += "ORDER BY matches DESC, subcnt DESC LIMIT ?" args = append(args, a.maxResults) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } // Get users matched by tags, sort by number of matches from high to low. rows, err := a.db.QueryxContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() // Read results as subscriptions. var public, trusted any var access t.DefaultAccess var subcnt int var setTags t.StringSlice var ignored int var isChan bool var sub t.Subscription var subs []t.Subscription for rows.Next() { if err = rows.Scan(&sub.Topic, &sub.CreatedAt, &sub.UpdatedAt, &isChan, &access, &subcnt, &public, &trusted, &setTags, &ignored); err != nil { subs = nil break } if id, err := strconv.ParseInt(sub.Topic, 10, 64); err == nil { sub.Topic = store.EncodeUid(id).UserId() if sub.Topic == caller { // Skip the caller. continue } } if isChan { // This is a channel, convert grp to chn name: all channel-capable // topics should appear as channels in search results. sub.Topic = t.GrpToChn(sub.Topic) } sub.SetSubCnt(subcnt) sub.SetPublic(common.FromJSON(public)) sub.SetTrusted(common.FromJSON(trusted)) sub.SetDefaultAccess(access.Auth, access.Anon) // Indicating that the mode is not set, not 'N'. sub.ModeGiven = t.ModeUnset sub.ModeWant = t.ModeUnset sub.Private = common.FilterFoundTags(setTags, index) subs = append(subs, sub) } if err == nil { err = rows.Err() } return subs, err } // FindOne returns the first topic which matches the given tag. func (a *adapter) FindOne(tag string) (string, error) { query := "SELECT t.name AS topic FROM im_topics AS t LEFT JOIN im_topictags AS tt ON t.name=tt.topic " + "WHERE tt.tag=? LIMIT 1" ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, query, tag) if err != nil { return "", err } defer rows.Close() var found string if rows.Next() { if err = rows.Scan(&found); err != nil { return "", err } // Check if the found value is a topic name or a user ID. // User IDs are returned as decoded decimal strings. if id, err := strconv.ParseInt(found, 10, 64); err == nil { found = store.EncodeUid(id).UserId() } } if err == nil { err = rows.Err() } return found, err } // Messages func (a *adapter) MessageSave(msg *t.Message) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } // store assignes message ID, but we don't use it. Message IDs are not used anywhere. // Using a sequential ID provided by the database. clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} res, err := a.db.ExecContext(ctx, "INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,`from`,client_msg_id,version,head,content,content_nonce,key_version) VALUES(?,?,?,?,?,?,?,?,?,?,?)", msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic, store.DecodeUid(t.ParseUid(msg.From)), clientMsgID, messageVersion(msg.Version), msg.Head, common.ToJSON(msg.Content), msg.ContentNonce, msg.KeyVersion) if isDupe(err) { return t.ErrDuplicate } if err == nil { id, _ := res.LastInsertId() // Replacing ID given by store by ID given by the DB. msg.SetUid(t.Uid(id)) } return err } // MessageGetAll returns messages matching the query. func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) { var limit = a.maxMessageResults args := []any{store.DecodeUid(forUser), topic} seqIdConstraint := "" if opts != nil { seqIdConstraint = "AND m.seqid " if len(opts.IdRanges) > 0 { constr, newargs := common.RangesToSql(opts.IdRanges) seqIdConstraint += constr args = append(args, newargs...) } else { seqIdConstraint += "BETWEEN ? AND ?" if opts.Since > 0 { args = append(args, opts.Since) } else { args = append(args, 0) } if opts.Before > 1 { // MySQL BETWEEN is inclusive-inclusive, Tinode API requires inclusive-exclusive, thus -1 args = append(args, opts.Before-1) } else { args = append(args, 1<<31-1) } } if opts.Limit > 0 && opts.Limit < limit { limit = opts.Limit } } args = append(args, limit) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext( ctx, "SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.head,m.content,m.content_nonce,m.key_version"+ " FROM im_messages AS m LEFT JOIN im_dellog AS d"+ " ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+ " WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+ " ORDER BY m.seqid DESC LIMIT ?", args...) if err != nil { return nil, err } defer rows.Close() msgs := make([]t.Message, 0, limit) for rows.Next() { var msg t.Message if err = rows.StructScan(&msg); err != nil { break } msg.From = common.EncodeUidString(msg.From).String() msg.Content = common.FromJSON(msg.Content) msgs = append(msgs, msg) } if err == nil { err = rows.Err() } return msgs, err } // Get ranges of deleted messages func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { var limit = a.maxResults var lower int64 = 0 var upper int64 = 1<<31 - 1 if opts != nil { if opts.Since > 0 { lower = opts.Since } if opts.Before > 1 { // DelRange is inclusive-exclusive, while BETWEEN is inclusive-inclisive. upper = opts.Before - 1 } if opts.Limit > 0 && opts.Limit < limit { limit = opts.Limit } } // Fetch log of deletions ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, "SELECT topic,deletedfor,delid,low,hi FROM im_dellog WHERE topic=? AND delid BETWEEN ? AND ?"+ " AND (deletedFor=0 OR deletedFor=?)"+ " ORDER BY delid LIMIT ?", topic, lower, upper, store.DecodeUid(forUser), limit) if err != nil { return nil, err } defer rows.Close() var dellog struct { Topic string Deletedfor int64 Delid int Low int64 Hi int64 } var dmsgs []t.DelMessage var dmsg t.DelMessage for rows.Next() { if err = rows.StructScan(&dellog); err != nil { dmsgs = nil break } if dellog.Delid != dmsg.DelId { if dmsg.DelId > 0 { dmsgs = append(dmsgs, dmsg) } dmsg.DelId = dellog.Delid dmsg.Topic = dellog.Topic if dellog.Deletedfor > 0 { dmsg.DeletedFor = store.EncodeUid(dellog.Deletedfor).String() } else { dmsg.DeletedFor = "" } dmsg.SeqIdRanges = nil } if dellog.Hi <= dellog.Low+1 { dellog.Hi = 0 } dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: dellog.Low, Hi: dellog.Hi}) } if err == nil { err = rows.Err() } if err == nil { if dmsg.DelId > 0 { dmsgs = append(dmsgs, dmsg) } } return dmsgs, err } func messageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error { var err error if toDel == nil { // Whole topic is being deleted, thus also deleting all messages. _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic) if err == nil { _, err = tx.Exec("DELETE FROM im_messages WHERE topic=?", topic) } // filemsglinks will be deleted because of ON DELETE CASCADE return err } // Only some messages are being deleted. delRanges := toDel.SeqIdRanges if toDel.DeletedFor == "" { // Hard-deleting messages requires updates to the messages table. where := "m.topic=?" args := []any{topic} if len(delRanges) > 0 { rSql, rArgs := common.RangesToSql(delRanges) where += " AND m.seqid " + rSql args = append(args, rArgs...) } where += " AND m.deletedat IS NULL" // We are asked to delete messages no older than newerThan. if newerThan := toDel.GetNewerThan(); newerThan != nil { where += " AND m.createdat>?" args = append(args, newerThan) } // Find the actual IDs still present in the database. var seqIDs []int64 err = tx.Select(&seqIDs, "SELECT seqid FROM im_messages AS m WHERE "+where, args...) if err != nil { return err } if len(seqIDs) == 0 { // Nothing to delete. No need to make a log entry. All done. return nil } // Recalculate the actual ranges to delete. sort.Slice(seqIDs, func(i, j int) bool { return seqIDs[i] < seqIDs[j] }) delRanges = t.SliceToRanges(seqIDs) // Compose a new query with the new ranges. where = "m.topic=?" args = []any{topic} rSql, rArgs := common.RangesToSql(delRanges) where += " AND m.seqid " + rSql args = append(args, rArgs...) // No need to add anything else: deletedat etc is already accounted for. _, err = tx.Exec("DELETE fml.* FROM im_filemsglinks AS fml INNER JOIN im_messages AS m ON m.id=fml.msgid WHERE "+ where, args...) if err != nil { return err } // Instead of deleting messages, clear all content. _, err = tx.Exec("UPDATE im_messages AS m SET m.deletedat=?,m.delId=?,m.`from`=0,m.head=NULL,m.content=NULL WHERE "+ where, append([]any{t.TimeNow(), toDel.DelId}, args...)...) if err != nil { return err } } // Now make log entries. Needed for both hard- and soft-deleting. var insert *sql.Stmt if insert, err = tx.Prepare( "INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)"); err != nil { return err } forUser := common.DecodeUidString(toDel.DeletedFor) for _, rng := range delRanges { if rng.Hi == 0 { // Dellog must contain valid Low and *Hi*. rng.Hi = rng.Low + 1 } // A log entry for each range. if _, err = insert.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil { break } } return err } // MessageDeleteList deletes messages in the given topic with seqIds from the list. func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) (err error) { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() if err = messageDeleteList(tx, topic, toDel); err != nil { return err } return tx.Commit() } func deviceHasher(deviceID string) string { // Generate custom key as [64-bit hash of device id] to ensure predictable // length of the key hasher := fnv.New64() hasher.Write([]byte(deviceID)) return strconv.FormatUint(uint64(hasher.Sum64()), 16) } // Device management for push notifications. // DeviceUpsert creates or updates a device record. func (a *adapter) DeviceUpsert(uid t.Uid, def *t.DeviceDef) error { hash := deviceHasher(def.DeviceId) ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() // Ensure uniqueness of the device ID: delete all records of the device ID _, err = tx.Exec("DELETE FROM im_devices WHERE hash=?", hash) if err != nil { return err } // Actually add/update DeviceId for the new user _, err = tx.Exec("INSERT INTO im_devices(userid, hash, deviceId, platform, lastseen, lang) VALUES(?,?,?,?,?,?)", store.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang) if err != nil { return err } return tx.Commit() } // DeviceGetAll returns all devices for a given set of users. func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { var unums []any for _, uid := range uids { unums = append(unums, store.DecodeUid(uid)) } q, unums, _ := sqlx.In("SELECT userid,deviceid,platform,lastseen,lang FROM im_devices WHERE userid IN (?)", unums) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } rows, err := a.db.QueryxContext(ctx, q, unums...) if err != nil { return nil, 0, err } defer rows.Close() var device struct { Userid int64 Deviceid string Platform string Lastseen time.Time Lang string } result := make(map[t.Uid][]t.DeviceDef) count := 0 for rows.Next() { if err = rows.StructScan(&device); err != nil { break } uid := store.EncodeUid(device.Userid) udev := result[uid] udev = append(udev, t.DeviceDef{ DeviceId: device.Deviceid, Platform: device.Platform, LastSeen: device.Lastseen, Lang: device.Lang, }) result[uid] = udev count++ } if err == nil { err = rows.Err() } return result, count, err } func deviceDelete(tx *sqlx.Tx, uid t.Uid, deviceID string) error { var err error var res sql.Result if deviceID == "" { res, err = tx.Exec("DELETE FROM im_devices WHERE userid=?", store.DecodeUid(uid)) } else { res, err = tx.Exec("DELETE FROM im_devices WHERE userid=? AND hash=?", store.DecodeUid(uid), deviceHasher(deviceID)) } if err == nil { if count, _ := res.RowsAffected(); count == 0 { err = t.ErrNotFound } } return err } // DeviceDelete deletes a device record (push token). func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() err = deviceDelete(tx, uid, deviceID) if err != nil { return err } return tx.Commit() } // Credential management // CredUpsert adds or updates a validation record. Returns true if inserted, false if updated. // 1. if credential is validated: // 1.1 Hard-delete unconfirmed equivalent record, if exists. // 1.2 Insert new. Report error if duplicate. // 2. if credential is not validated: // 2.1 Check if validated equivalent exist. If so, report an error. // 2.2 Soft-delete all unvalidated records of the same method. // 2.3 Undelete existing credential. Return if successful. // 2.4 Insert new credential record. func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { var err error if cred == nil { return false, t.ErrMalformed } if cred.Method == "tel" { cred.Value, err = normalizeUserPhone(cred.Value) if err != nil { return false, err } } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return false, err } defer func() { if err != nil { tx.Rollback() } }() now := t.TimeNow() userId := common.DecodeUidString(cred.User) // Enforce uniqueness: if credential is confirmed, "method:value" must be unique. // if credential is not yet confirmed, "userid:method:value" is unique. synth := cred.Method + ":" + cred.Value if !cred.Done { // Check if this credential is already validated. var done bool err = tx.Get(&done, "SELECT done FROM im_credentials WHERE synthetic=?", synth) if err == nil { // Assign err to ensure closing of a transaction. err = t.ErrDuplicate return false, err } if err != sql.ErrNoRows { return false, err } // We are going to insert new record. synth = cred.User + ":" + synth // Adding new unvalidated credential. Deactivate all unvalidated records of this user and method. _, err = tx.Exec("UPDATE im_credentials SET deletedat=? WHERE userid=? AND method=? AND done=FALSE", now, userId, cred.Method) if err != nil { return false, err } // Assume that the record exists and try to update it: undelete, update timestamp and response value. res, err := tx.Exec("UPDATE im_credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE synthetic=?", cred.UpdatedAt, cred.Resp, synth) if err != nil { return false, err } // If record was updated, then all is fine. if numrows, _ := res.RowsAffected(); numrows > 0 { return false, tx.Commit() } } else { // Hard-deleting unconformed record if it exists. _, err = tx.Exec("DELETE FROM im_credentials WHERE synthetic=?", cred.User+":"+synth) if err != nil { return false, err } } // Add new record. credID, err := nextSnowflakeID() if err != nil { return false, err } _, err = tx.Exec("INSERT INTO im_credentials(id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+ "VALUES(?,?,?,?,?,?,?,?,?)", credID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done) if isIDOutOfRange(err) { _, err = tx.Exec("INSERT INTO im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+ "VALUES(?,?,?,?,?,?,?,?)", cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userId, cred.Resp, cred.Done) } if err != nil { if isDupe(err) { return true, t.ErrDuplicate } return true, err } if cred.Done && cred.Method == "tel" { if _, err = tx.Exec("UPDATE im_users SET phone=? WHERE id=? AND (phone IS NULL OR phone='')", cred.Value, userId); err != nil { return true, err } } return true, tx.Commit() } // TenantCredUpsert adds or updates a credential scoped to a tenant. func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error) { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil || cred == nil { return false, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantCredUpsert(tenantID, cred) } if cred.Method == "tel" { cred.Value, err = normalizeUserPhone(cred.Value) if err != nil { return false, err } } 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() now := t.TimeNow() userID := common.DecodeUidString(cred.User) synth := cred.Method + ":" + cred.Value if !cred.Done { var done bool err = tx.Get(&done, "SELECT done FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, synth) if err == nil { return false, t.ErrDuplicate } if err != sql.ErrNoRows { return false, err } synth = cred.User + ":" + synth if _, err = tx.Exec("UPDATE im_credentials SET deletedat=? WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE", now, tenantID, userID, cred.Method); err != nil { return false, err } res, updateErr := tx.Exec("UPDATE im_credentials SET updatedat=?,deletedat=NULL,resp=?,done=FALSE WHERE tenant_id=? AND synthetic=?", cred.UpdatedAt, cred.Resp, tenantID, synth) if updateErr != nil { return false, updateErr } if rows, _ := res.RowsAffected(); rows > 0 { return false, tx.Commit() } } else if _, err = tx.Exec("DELETE FROM im_credentials WHERE tenant_id=? AND synthetic=?", tenantID, cred.User+":"+synth); err != nil { return false, err } credID, err := nextSnowflakeID() if err != nil { return false, err } _, err = tx.Exec(`INSERT INTO im_credentials(tenant_id,id,createdat,updatedat,method,value,synthetic,userid,resp,done) VALUES(?,?,?,?,?,?,?,?,?,?)`, tenantID, credID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userID, cred.Resp, cred.Done) if isIDOutOfRange(err) { _, err = tx.Exec(`INSERT INTO im_credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) VALUES(?,?,?,?,?,?,?,?,?)`, tenantID, cred.CreatedAt, cred.UpdatedAt, cred.Method, cred.Value, synth, userID, cred.Resp, cred.Done) } if err != nil { if isDupe(err) { return true, t.ErrDuplicate } return true, err } if cred.Done && cred.Method == "tel" { if _, err = tx.Exec("UPDATE im_users SET phone=? WHERE tenant_id=? AND id=? AND (phone IS NULL OR phone='')", cred.Value, tenantID, userID); err != nil { return true, err } } return true, tx.Commit() } func tenantCredDel(tx *sqlx.Tx, tenantID t.TenantID, uid t.Uid, method, value string) error { constraints := " WHERE tenant_id=? AND userid=?" args := []any{tenantID, store.DecodeUid(uid)} if method != "" { constraints += " AND method=?" args = append(args, method) if value != "" { constraints += " AND value=?" args = append(args, value) } } if method == "" { res, err := tx.Exec("DELETE FROM im_credentials"+constraints, args...) if err != nil { return err } if count, _ := res.RowsAffected(); count == 0 { return t.ErrNotFound } return nil } res, err := tx.Exec("DELETE FROM im_credentials"+constraints+" AND (done=TRUE OR retries=0)", args...) if err != nil { return err } if count, _ := res.RowsAffected(); count > 0 { return nil } args = append([]any{t.TimeNow()}, args...) res, err = tx.Exec("UPDATE im_credentials SET deletedat=?"+constraints, args...) if err != nil { return err } if count, _ := res.RowsAffected(); count == 0 { return t.ErrNotFound } return nil } // credDel deletes given validation method or all methods of the given user. // 1. If user is being deleted, hard-delete all records (method == "") // 2. If one value is being deleted: // 2.1 Delete it if it's valiated or if there were no attempts at validation // (otherwise it could be used to circumvent the limit on validation attempts). // 2.2 In that case mark it as soft-deleted. func credDel(tx *sqlx.Tx, uid t.Uid, method, value string) error { constraints := " WHERE userid=?" args := []any{store.DecodeUid(uid)} if method != "" { constraints += " AND method=?" args = append(args, method) if value != "" { constraints += " AND value=?" args = append(args, value) } } var err error var res sql.Result if method == "" { // Case 1 res, err = tx.Exec("DELETE FROM im_credentials"+constraints, args...) if err == nil { if count, _ := res.RowsAffected(); count == 0 { err = t.ErrNotFound } } return err } // Case 2.1 res, err = tx.Exec("DELETE FROM im_credentials"+constraints+" AND (done=TRUE OR retries=0)", args...) if err != nil { return err } if count, _ := res.RowsAffected(); count > 0 { return nil } // Case 2.2 args = append([]any{t.TimeNow()}, args...) res, err = tx.Exec("UPDATE im_credentials SET deletedat=?"+constraints, args...) if err == nil { if count, _ := res.RowsAffected(); count >= 0 { err = t.ErrNotFound } } return err } // CredDel deletes either credentials of the given user. If method is blank all // credentials are removed. If value is blank all credentials of the given the // method are removed. func (a *adapter) CredDel(uid t.Uid, method, value string) error { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() err = credDel(tx, uid, method, value) if err != nil { return err } return tx.Commit() } // TenantCredDel deletes credentials only in the selected tenant. func (a *adapter) TenantCredDel(tenantID t.TenantID, uid t.Uid, method, value string) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantCredDel(tenantID, uid, method, value) } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } if err = tenantCredDel(tx, tenantID, uid, method, value); err != nil { tx.Rollback() return err } return tx.Commit() } // CredConfirm marks given credential method as confirmed. func (a *adapter) CredConfirm(uid t.Uid, method string) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } res, err := a.db.ExecContext( ctx, "UPDATE im_credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+ "WHERE userid=? AND method=? AND deletedat IS NULL AND done=FALSE", t.TimeNow(), store.DecodeUid(uid), method) if err != nil { if isDupe(err) { return t.ErrDuplicate } return err } if numrows, _ := res.RowsAffected(); numrows < 1 { return t.ErrNotFound } return nil } // TenantCredConfirm confirms a credential only in the selected tenant. func (a *adapter) TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method string) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantCredConfirm(tenantID, uid, method) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } res, err := a.db.ExecContext(ctx, "UPDATE im_credentials SET updatedat=?,done=TRUE,synthetic=CONCAT(method,':',value) "+ "WHERE tenant_id=? AND userid=? AND method=? AND deletedat IS NULL AND done=FALSE", t.TimeNow(), tenantID, store.DecodeUid(uid), method) if isDupe(err) { return t.ErrDuplicate } if err != nil { return err } if numRows, _ := res.RowsAffected(); numRows < 1 { return t.ErrNotFound } return nil } // CredFail increments failure count of the given validation method. func (a *adapter) CredFail(uid t.Uid, method string) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "UPDATE im_credentials SET updatedat=?,retries=retries+1 WHERE userid=? AND method=? AND done=FALSE", t.TimeNow(), store.DecodeUid(uid), method) return err } // TenantCredFail increments credential failures only in the selected tenant. func (a *adapter) TenantCredFail(tenantID t.TenantID, uid t.Uid, method string) error { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantCredFail(tenantID, uid, method) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err = a.db.ExecContext(ctx, "UPDATE im_credentials SET updatedat=?,retries=retries+1 "+ "WHERE tenant_id=? AND userid=? AND method=? AND done=FALSE", t.TimeNow(), tenantID, store.DecodeUid(uid), method) return err } // CredGetActive returns currently active unvalidated credential of the given user and method. func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var cred t.Credential err := a.db.GetContext(ctx, &cred, "SELECT createdat,updatedat,method,value,resp,done,retries "+ "FROM im_credentials WHERE userid=? AND deletedat IS NULL AND method=? AND done=FALSE", store.DecodeUid(uid), method) if err != nil { if err == sql.ErrNoRows { err = nil } return nil, err } cred.User = uid.String() return &cred, nil } // TenantCredGetActive returns the active credential in the selected tenant. func (a *adapter) TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method string) (*t.Credential, error) { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return nil, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantCredGetActive(tenantID, uid, method) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var cred t.Credential err = a.db.GetContext(ctx, &cred, `SELECT createdat,updatedat,method,value,resp,done,retries FROM im_credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL AND method=? AND done=FALSE`, tenantID, store.DecodeUid(uid), method) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } cred.User = uid.String() return &cred, nil } // CredGetAll returns credential records for the given user and method, all or validated only. func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) { query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM im_credentials WHERE userid=? AND deletedat IS NULL" args := []any{store.DecodeUid(uid)} if method != "" { query += " AND method=?" args = append(args, method) } if validatedOnly { query += " AND done=TRUE" } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var credentials []t.Credential err := a.db.SelectContext(ctx, &credentials, query, args...) if err != nil { return nil, err } user := uid.String() for i := range credentials { credentials[i].User = user } return credentials, err } // TenantCredGetAll returns credentials only from the selected tenant. func (a *adapter) TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) { tenantAdp, err := a.routedTenantAdapter(tenantID) if err != nil { return nil, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantCredGetAll(tenantID, uid, method, validatedOnly) } query := `SELECT createdat,updatedat,method,value,resp,done,retries FROM im_credentials WHERE tenant_id=? AND userid=? AND deletedat IS NULL` args := []any{tenantID, store.DecodeUid(uid)} if method != "" { query += " AND method=?" args = append(args, method) } if validatedOnly { query += " AND done=TRUE" } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var credentials []t.Credential if err = a.db.SelectContext(ctx, &credentials, query, args...); err != nil { return nil, err } for i := range credentials { credentials[i].User = uid.String() } return credentials, nil } // FileUploads // FileStartUpload initializes a file upload func (a *adapter) FileStartUpload(fd *t.FileDef) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var user any if fd.User != "" { user = store.DecodeUid(t.ParseUid(fd.User)) } else { user = 0 } _, err := a.db.ExecContext(ctx, "INSERT INTO im_fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+ "VALUES(?,?,?,?,?,?,?,?,?)", store.DecodeUid(fd.Uid()), fd.CreatedAt, fd.UpdatedAt, user, fd.Status, fd.MimeType, fd.Size, fd.ETag, fd.Location) return err } // FileFinishUpload marks file upload as completed, successfully or otherwise func (a *adapter) FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if err != nil { tx.Rollback() } }() now := t.TimeNow() if success { _, err = tx.ExecContext(ctx, "UPDATE im_fileuploads SET updatedat=?,status=?,size=?,etag=?,location=? WHERE id=?", now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid())) if err != nil { return nil, err } fd.UpdatedAt = now fd.Size = size if err = insertFileReviewTasks(ctx, tx, fd); err != nil { return nil, err } fd.Status = t.UploadCompleted } else { // Deleting the record: there is no value in keeping it in the DB. _, err = tx.ExecContext(ctx, "DELETE FROM im_fileuploads WHERE id=?", store.DecodeUid(fd.Uid())) if err != nil { return nil, err } fd.Status = t.UploadFailed fd.Size = 0 } fd.UpdatedAt = now return fd, tx.Commit() } // FileGet fetches a record of a specific file func (a *adapter) FileGet(fid string) (*t.FileDef, error) { id := parseFileUID(fid) if id.IsZero() { return nil, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var fd t.FileDef err := a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ "FROM im_fileuploads WHERE id=?", store.DecodeUid(id)) if err == sql.ErrNoRows { storageName := id.String32() err = a.db.GetContext(ctx, &fd, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,IFNULL(etag,'') AS etag,location "+ "FROM im_fileuploads WHERE location=? OR location LIKE ? ORDER BY createdat DESC LIMIT 1", storageName, "%/"+storageName) } if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } fd.Id = common.EncodeUidString(fd.Id).String() fd.User = common.EncodeUidString(fd.User).String() return &fd, nil } // FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes // unused records with UpdatedAt before olderThan. // Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too. func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) { ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return nil, err } defer func() { if err != nil { tx.Rollback() } }() // Garbage collecting entries which as either marked as deleted, or lack message references, or have no user assigned. query := "SELECT fu.id,fu.location FROM im_fileuploads AS fu LEFT JOIN im_filemsglinks AS fml ON fml.fileid=fu.id " + "WHERE fml.id IS NULL" var args []any if !olderThan.IsZero() { query += " AND fu.updatedat 0 { query += " LIMIT ?" args = append(args, limit) } rows, err := tx.Query(query, args...) if err != nil { return nil, err } type gcCandidate struct { id int location string } var candidates []gcCandidate for rows.Next() { var id int var loc string if err = rows.Scan(&id, &loc); err != nil { break } candidates = append(candidates, gcCandidate{id: id, location: loc}) } if err == nil { err = rows.Err() } rows.Close() if err != nil { return nil, err } var locations []string var ids []any for _, candidate := range candidates { if candidate.location != "" { locations = append(locations, candidate.location) } ids = append(ids, candidate.id) } if err != nil { return nil, err } if len(ids) > 0 { query, ids, _ = sqlx.In("DELETE FROM im_fileuploads WHERE id IN (?)", ids) _, err = tx.Exec(query, ids...) if err != nil { return nil, err } } return locations, tx.Commit() } // FileLinkAttachments connects given topic or message to the file record IDs from the list. func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error { if len(fids) == 0 || (topic == "" && msgId.IsZero() && userId.IsZero()) { return t.ErrMalformed } now := t.TimeNow() var args []any var linkId any var linkBy string if !msgId.IsZero() { linkBy = "msgid" linkId = int64(msgId) } else if topic != "" { linkBy = "topic" linkId = topic // Only one attachment per topic is permitted at this time. fids = fids[0:1] } else { linkBy = "userid" linkId = store.DecodeUid(userId) // Only one attachment per user is permitted at this time. fids = fids[0:1] } // Decoded ids var dids []any for _, fid := range fids { id := t.ParseUid(fid) if id.IsZero() { return t.ErrMalformed } dids = append(dids, store.DecodeUid(id)) } var existing int query, queryArgs, err := sqlx.In("SELECT COUNT(*) FROM im_fileuploads WHERE id IN (?) AND status=?", dids, t.UploadCompleted) if err != nil { return err } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } if err = a.db.GetContext(ctx, &existing, a.db.Rebind(query), queryArgs...); err != nil { return err } if existing != len(dids) { return t.ErrNotFound } for _, id := range dids { // createdat,fileid,[msgid|topic|userid] args = append(args, now, id, linkId) } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { if err != nil { tx.Rollback() } }() // Unlink earlier uploads on the same topic or user allowing them to be garbage-collected. if msgId.IsZero() { sql := "DELETE FROM im_filemsglinks WHERE " + linkBy + "=?" _, err = tx.Exec(sql, linkId) if err != nil { return err } } sql := "INSERT INTO im_filemsglinks(createdat,fileid," + linkBy + ") VALUES (?,?,?)" _, err = tx.Exec(sql+strings.Repeat(",(?,?,?)", len(dids)-1), args...) if err != nil { return err } return tx.Commit() } func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) { if uid.IsZero() { return false, t.ErrMalformed } fileID := parseFileUID(fid) if fileID.IsZero() { return false, t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var allowed int buildQuery := func() (string, []any) { args := []any{ store.DecodeUid(fileID), fileID.String32(), "%/" + fileID.String32(), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), t.RelationshipFriend, store.DecodeUid(uid), store.DecodeUid(uid), t.GroupMemberStatusActive, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), fid, fid, } return ` SELECT COUNT(*) FROM im_fileuploads AS fu WHERE (fu.id=? OR fu.location=? OR fu.location LIKE ?) AND fu.status=? AND ( fu.userid=? OR EXISTS ( SELECT 1 FROM im_filemsglinks AS fml WHERE fml.fileid=fu.id AND fml.topic LIKE 'cvp%' ) OR EXISTS ( SELECT 1 FROM im_filemsglinks AS fml WHERE fml.fileid=fu.id AND fml.userid=? ) OR EXISTS ( SELECT 1 FROM im_filemsglinks AS fml INNER JOIN im_friendships AS fs ON fs.user_id=? AND fs.peer_id=fml.userid AND fs.status=? WHERE fml.fileid=fu.id AND fml.userid IS NOT NULL ) OR EXISTS ( SELECT 1 FROM im_filemsglinks AS fml INNER JOIN im_subscriptions AS s ON s.topic=fml.topic AND s.userid=? WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 ) OR EXISTS ( SELECT 1 FROM im_filemsglinks AS fml INNER JOIN im_group_members AS gm ON gm.topic=fml.topic AND gm.user_id=? AND gm.member_status=? WHERE fml.fileid=fu.id AND fml.topic IS NOT NULL ) OR EXISTS ( SELECT 1 FROM im_filemsglinks AS fml INNER JOIN im_messages AS m ON m.id=fml.msgid INNER JOIN im_subscriptions AS s ON s.topic=m.topic AND s.userid=? WHERE fml.fileid=fu.id AND fml.msgid IS NOT NULL AND m.deletedat IS NULL AND m.recalled_at IS NULL AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 AND NOT EXISTS ( SELECT 1 FROM im_dellog AS d WHERE d.topic=m.topic AND d.deletedfor=? AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid) ) ) OR EXISTS ( SELECT 1 FROM im_group_files AS gf INNER JOIN im_subscriptions AS s ON s.topic=gf.topic AND s.userid=? WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%')) AND gf.status='active' AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 ) ) LIMIT 1`, args } run := func() error { query, args := buildQuery() return a.db.GetContext(ctx, &allowed, query, args...) } err := run() for attempt := 0; isRetryableMySQLError(err) && attempt < 2; attempt++ { time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond) err = run() } if err != nil { return false, err } return allowed > 0, nil } // PCacheGet reads a persistet cache entry. func (a *adapter) PCacheGet(key string) (string, error) { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var value string if err := a.db.GetContext(ctx, &value, "SELECT `value` FROM im_kvmeta WHERE `key`=? LIMIT 1", key); err != nil { if err == sql.ErrNoRows { return "", t.ErrNotFound } return "", err } return value, nil } // PCacheUpsert creates or updates a persistent cache entry. func (a *adapter) PCacheUpsert(key string, value string, failOnDuplicate bool) error { if strings.Contains(key, "%") { // Do not allow % in keys: it interferes with LIKE query. return t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var action string if failOnDuplicate { action = "INSERT" } else { action = "REPLACE" } id, err := nextSnowflakeID() if err != nil { return err } _, err = a.db.ExecContext(ctx, action+" INTO im_kvmeta(id,`key`,createdat,`value`) VALUES(?,?,?,?)", id, key, t.TimeNow(), value) if isDupe(err) { return t.ErrDuplicate } return err } // PCacheDelete deletes one persistent cache entry. func (a *adapter) PCacheDelete(key string) error { ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "DELETE FROM im_kvmeta WHERE `key`=?", key) return err } // PCacheExpire expires old entries with the given key prefix. func (a *adapter) PCacheExpire(keyPrefix string, olderThan time.Time) error { if keyPrefix == "" { return t.ErrMalformed } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } _, err := a.db.ExecContext(ctx, "DELETE FROM im_kvmeta WHERE `key` LIKE ? AND createdat