Files
im_server/server/db/mysql/adapter.go
T

5344 lines
159 KiB
Go

//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
tenantRegistryDB string
tenantAdapters map[t.TenantID]*adapter
tenantMu sync.Mutex
}
const (
adpVersion = 123
adapterName = "mysql"
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
defaultDatabase = "tinode"
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, "`", "``") + "`"
}
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("mysql", 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
}
// 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
}
config.Config.DBName = a.dbName
a.baseConfig = &config
a.tenantDBRouting = config.TenantDBRouting
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()
databaseMissing := isMissingDb(err)
if databaseMissing {
// Ignore missing database here. If we are initializing the database
// missing DB is OK.
err = nil
}
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 a.registryDB != nil || !databaseMissing {
controlDB := a.db
if a.registryDB != nil {
controlDB = a.registryDB
}
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
}
if err = ensureIMTenantEntryControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure tenant entry control schema: " + err.Error())
}
}
if !databaseMissing {
if err = ensureIMGroupBusinessTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure group business schema: " + err.Error())
}
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
}
if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
}
if err = ensureIMOrganizationVisibilityTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure organization visibility schema: " + err.Error())
}
if err = ensureIMAttendanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error())
}
if err = ensureIMMeetingNotesTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure meeting notes schema: " + err.Error())
}
if err = ensureSnowflakePrimaryKeys(a.db); err != nil {
return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error())
}
if err = ensureUserIdentitySchema(a.db); err != nil {
return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error())
}
if err = ensureIMRegistrationInviteUseTable(a.db); err != nil {
return errors.New("mysql adapter failed to ensure registration invite usage 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 {
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, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
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 isUnknownColumn(err) {
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 != 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, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
state, created_at, created_by, updated_at, updated_by
FROM im_tenant WHERE state=? ORDER BY id ASC LIMIT ?`, t.TenantStateActive, limit)
if isUnknownColumn(err) {
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 {
return a, a, nil
}
a.tenantMu.Lock()
if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil {
a.tenantMu.Unlock()
return tenantAdp, tenantAdp, nil
}
a.tenantMu.Unlock()
tenant, err := a.tenantByID(tenantID)
if err != nil {
return nil, nil, err
}
dbName := strings.TrimSpace(tenant.DBName)
dsn := strings.TrimSpace(tenant.DBDSN)
if a.baseConfig == nil {
return nil, nil, t.ErrFailed
}
db, resolvedDBName, err := openMySQLDB(*a.baseConfig, mysqlDefaultString(dsn, a.dsn), dbName)
if err != nil {
return nil, nil, err
}
if err = db.Ping(); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMGroupBusinessTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMHTTPInfrastructureTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMFileReviewTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMOrganizationVisibilityTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMAttendanceTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMMeetingNotesTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureSnowflakePrimaryKeys(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureUserIdentitySchema(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMRegistrationInviteUseTable(db); err != nil {
db.Close()
return nil, nil, err
}
registryDB := a.registryDB
if registryDB == nil {
registryDB = a.db
}
tenantAdp := &adapter{
db: db,
registryDB: registryDB,
dsn: mysqlDefaultString(dsn, a.dsn),
dbName: resolvedDBName,
maxResults: a.maxResults,
maxMessageResults: a.maxMessageResults,
version: a.version,
sqlTimeout: a.sqlTimeout,
txTimeout: a.txTimeout,
baseConfig: a.baseConfig,
tenantDBRouting: false,
tenantScopedDB: true,
}
a.tenantMu.Lock()
if existing := a.tenantAdapters[tenantID]; existing != nil {
a.tenantMu.Unlock()
db.Close()
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, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
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 isUnknownColumn(err) {
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 != nil {
return nil, err
}
if !tenant.IsActive() {
return nil, t.ErrPermissionDenied
}
return &tenant, nil
}
func createTenantRegistry(tx *sql.Tx, registryDB, businessDB 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 AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
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),
INDEX idx_im_tenant_db_name(db_name)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE IF NOT EXISTS im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
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_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`); err != nil {
return err
}
_, err := tx.Exec(
`INSERT INTO im_tenant(id,code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES(1,'default','Default Tenant',NULL,?,'',?,0,0)
ON DUPLICATE KEY UPDATE db_name=VALUES(db_name), state=VALUES(state), updated_by=VALUES(updated_by)`,
businessDB, t.TenantStateActive)
return err
}
// CreateDb initializes the storage.
func (a *adapter) CreateDb(reset bool) error {
var err error
var tx *sql.Tx
// Can't use an existing connection because it's configured with a database name which may not exist.
// Don't care if it does not close cleanly.
a.db.Close()
// This DSN has been parsed before and produced no error, not checking for errors here.
cfg, _ := ms.ParseDSN(a.dsn)
// Clear database name
cfg.DBName = ""
a.db, err = sqlx.Open("mysql", cfg.FormatDSN())
if err != nil {
return err
}
if tx, err = a.db.Begin(); err != nil {
return err
}
defer func() {
if err != nil {
// FIXME: This is useless: MySQL auto-commits on every CREATE TABLE.
// Maybe DROP DATABASE instead.
tx.Rollback()
}
}()
registryDB := a.tenantRegistryDB
if registryDB == "" {
registryDB = "hotgo"
}
businessDB := a.dbName
if businessDB == "" {
businessDB = defaultDatabase
}
if reset {
if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil {
return err
}
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("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant_config"); err != nil {
return err
}
if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant"); err != nil {
return err
}
}
if err = createTenantRegistry(tx, registryDB, businessDB); err != nil {
return err
}
if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(businessDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
return err
}
if _, err = tx.Exec("USE " + quoteIdent(businessDB)); err != nil {
return err
}
if _, err = tx.Exec(
`CREATE TABLE im_users(
id BIGINT NOT NULL,
username VARCHAR(32) NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
stateat DATETIME(3),
access JSON,
lastseen DATETIME,
useragent VARCHAR(255) DEFAULT '',
phone VARCHAR(32),
public JSON,
trusted JSON,
PRIMARY KEY(id),
UNIQUE INDEX users_username(username),
UNIQUE INDEX users_phone(phone),
INDEX users_state_stateat(state, stateat),
INDEX users_lastseen_updatedat(lastseen, updatedat)
)`); err != nil {
return err
}
if _, err = tx.Exec(
`CREATE TABLE im_user_security(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_security_user(user_id)
)`); err != nil {
return err
}
if err = createIMSecurityTables(tx); err != nil {
return err
}
if err = createIMNotificationTables(tx); err != nil {
return err
}
if err = ensureIMRegistrationInviteUseTable(tx); err != nil {
return err
}
if a.registryDB == nil {
if err = ensureIMRegistrationInviteControlTables(tx); err != nil {
return err
}
}
// Indexed devices. Normalized into a separate table.
if _, err = tx.Exec(
`CREATE TABLE im_devices(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
hash CHAR(16) NOT NULL,
deviceid TEXT NOT NULL,
platform VARCHAR(32),
lastseen DATETIME NOT NULL,
lang VARCHAR(8),
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
UNIQUE INDEX devices_hash(hash)
)`); err != nil {
return err
}
// Authentication records for the basic authentication scheme.
if _, err = tx.Exec(
`CREATE TABLE im_auth(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
authlvl INT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
UNIQUE INDEX auth_userid(userid)
)`); err != nil {
return err
}
// Topics
if _, err = tx.Exec(
`CREATE TABLE im_topics(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
stateat DATETIME(3),
touchedat DATETIME(3),
name CHAR(25) NOT NULL,
usebt TINYINT DEFAULT 0,
owner BIGINT NOT NULL DEFAULT 0,
access JSON,
seqid BIGINT NOT NULL DEFAULT 0,
delid INT DEFAULT 0,
subcnt INT DEFAULT 0,
public JSON,
trusted JSON,
tags JSON,
aux JSON,
PRIMARY KEY(id),
UNIQUE INDEX topics_name(name),
INDEX topics_owner(owner),
INDEX topics_state_stateat(state, stateat),
INDEX topics_name_state_seqid(name, state, seqid)
)`); err != nil {
return err
}
// Create system topic 'sys'.
if err = createSystemTopic(tx); err != nil {
return err
}
// Indexed topic tags.
if _, err = tx.Exec(
`CREATE TABLE im_topictags(
id INT NOT NULL AUTO_INCREMENT,
topic CHAR(25) NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES im_topics(name),
INDEX topictags_tag(tag),
UNIQUE INDEX topictags_topic_tag(topic, tag)
)`); err != nil {
return err
}
// Subscriptions
if _, err = tx.Exec(
`CREATE TABLE im_subscriptions(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
userid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,
delid INT DEFAULT 0,
recvseqid BIGINT DEFAULT 0,
readseqid BIGINT DEFAULT 0,
modewant CHAR(8),
modegiven CHAR(8),
private JSON,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
UNIQUE INDEX subscriptions_topic_userid(topic, userid),
INDEX subscriptions_topic(topic),
INDEX subscriptions_deletedat(deletedat),
INDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat)
)`); err != nil {
return err
}
// Messages
if _, err = tx.Exec(
`CREATE TABLE im_messages(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
delid INT DEFAULT 0,
seqid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,` +
"`from` BIGINT NOT NULL," +
`client_msg_id VARCHAR(64),
version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3),
recalled_by BIGINT,
head JSON,
content JSON,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES im_topics(name),
UNIQUE INDEX messages_sender_client_mid(` + "`from`" + `, client_msg_id),
UNIQUE INDEX messages_topic_seqid(topic, seqid)
)`); err != nil {
return err
}
if _, err = tx.Exec(
`CREATE TABLE im_message_revisions(
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid BIGINT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY(id),
UNIQUE INDEX uk_im_message_revisions_version(topic, seqid, version)
)`); err != nil {
return err
}
if err = createIMRelationTables(tx); err != nil {
return err
}
if err = createIMCallTables(tx); err != nil {
return err
}
if err = createIMWorkbenchTables(tx); err != nil {
return err
}
if err = createIMOrganizationTables(tx); err != nil {
return err
}
if err = createIMAttendanceTables(tx); err != nil {
return err
}
if err = createIMMeetingTables(tx); err != nil {
return err
}
if err = createIMReportTables(tx); err != nil {
return err
}
if err = createIMMailboxTables(tx); err != nil {
return err
}
if err = createIMUserProductTables(tx); err != nil {
return err
}
if err = ensureIMGroupBusinessTables(tx); err != nil {
return err
}
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
return err
}
if err = ensureIMFileReviewTables(tx); err != nil {
return err
}
// Deletion log
if _, err = tx.Exec(
`CREATE TABLE im_dellog(
id INT NOT NULL AUTO_INCREMENT,
topic CHAR(25) NOT NULL,
deletedfor BIGINT NOT NULL DEFAULT 0,
delid INT NOT NULL,
low INT NOT NULL,
hi INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES im_topics(name),
INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),
INDEX dellog_deletedfor(deletedfor)
)`); err != nil {
return err
}
// User credentials
if _, err = tx.Exec(
`CREATE TABLE im_credentials(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
method VARCHAR(16) NOT NULL,
value VARCHAR(128) NOT NULL,
synthetic VARCHAR(192) NOT NULL,
userid BIGINT NOT NULL,
resp VARCHAR(255),
done TINYINT NOT NULL DEFAULT 0,
retries INT NOT NULL DEFAULT 0,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
UNIQUE credentials_uniqueness(synthetic)
)`); err != nil {
return err
}
// Records of uploaded files.
// Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion.
if _, err = tx.Exec(
`CREATE TABLE im_fileuploads(
id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
userid BIGINT,
status INT NOT NULL,
mimetype VARCHAR(255) NOT NULL,
size BIGINT NOT NULL,
etag VARCHAR(128),
location VARCHAR(2048) NOT NULL,
PRIMARY KEY(id),
INDEX fileuploads_status(status)
)`); err != nil {
return err
}
// Links between uploaded files and the topics, users or messages they are attached to.
if _, err = tx.Exec(
`CREATE TABLE im_filemsglinks(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
fileid BIGINT NOT NULL,
msgid INT,
topic CHAR(25),
userid BIGINT,
PRIMARY KEY(id),
FOREIGN KEY(fileid) REFERENCES im_fileuploads(id) ON DELETE CASCADE,
FOREIGN KEY(msgid) REFERENCES im_messages(id) ON DELETE CASCADE,
FOREIGN KEY(topic) REFERENCES im_topics(name) ON DELETE CASCADE,
FOREIGN KEY(userid) REFERENCES im_users(id) ON DELETE CASCADE
)`); err != nil {
return err
}
if _, err = tx.Exec(
`CREATE TABLE im_kvmeta(` +
"id BIGINT NOT NULL," +
"`key` VARCHAR(64) NOT NULL," +
"createdat DATETIME(3)," +
"`value` TEXT," +
"PRIMARY KEY(id)," +
"UNIQUE INDEX uk_im_kvmeta_key(`key`)," +
"INDEX kvmeta_createdat_key(createdat, `key`)" +
`)`); err != nil {
return err
}
versionID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err = tx.Exec("INSERT INTO im_kvmeta(id, `key`, `value`) VALUES(?,'version',?)", versionID, adpVersion); err != nil {
return err
}
return tx.Commit()
}
// UpgradeDb upgrades the database, if necessary.
func (a *adapter) UpgradeDb() error {
if _, err := a.GetDbVersion(); err != nil {
return err
}
if err := a.ensureMessageTopicSeqIndex(); err != nil {
return err
}
if err := a.ensureTopicSeqMatchesMessages(); err != nil {
return err
}
if a.version == adpVersion {
return nil
}
return errors.New("mysql independent-database multitenancy requires a fresh schema; automatic upgrade from version " +
strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion) + " is not supported")
}
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) 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.seqid<m.max_seq`); err != nil {
return err
}
} else {
if _, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT topic,MAX(seqid) AS max_seq FROM im_messages GROUP BY topic) AS m ON m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`); err != nil {
return err
}
}
groupMessagesExist, err := identityTableExists(ctx, conn, "im_group_messages")
if err != nil || !groupMessagesExist {
return err
}
groupMessagesScoped, err := identityColumnExists(ctx, conn, "im_group_messages", "tenant_id")
if err != nil {
return err
}
if topicsScoped && groupMessagesScoped {
_, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT tenant_id,topic,MAX(seqid) AS max_seq FROM im_group_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.seqid<m.max_seq`)
return err
}
_, err = conn.ExecContext(ctx, `UPDATE im_topics AS t
JOIN (SELECT topic,MAX(seqid) AS max_seq FROM im_group_messages GROUP BY topic) AS m ON m.topic COLLATE utf8mb4_unicode_ci=t.name
SET t.seqid=GREATEST(t.seqid,m.max_seq)
WHERE t.seqid<m.max_seq`)
return err
}
func createIMNotificationTables(tx *sql.Tx) error {
_, err := tx.Exec(
`CREATE TABLE im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_system_notifications_event(user_id, event_id),
INDEX idx_im_system_notifications_inbox(user_id, id DESC)
)`)
return err
}
func createIMCallTables(tx *sql.Tx) error {
_, err := tx.Exec(
`CREATE TABLE im_call_records(
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id BIGINT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
duration_ms BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(3) NOT NULL,
accepted_at DATETIME(3),
finished_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_call_records_call(call_id),
UNIQUE INDEX uk_im_call_records_topic_seq(topic, seq_id),
INDEX idx_im_call_records_caller(caller_id, id DESC),
INDEX idx_im_call_records_callee(callee_id, id DESC),
INDEX idx_im_call_records_status(status, updated_at)
)`)
return err
}
func createIMWorkbenchTables(tx *sql.Tx) error {
if _, err := tx.Exec(
`CREATE TABLE im_workbench_apps(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
icon_file_id VARCHAR(64) NOT NULL DEFAULT '',
route VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
unread_key VARCHAR(64) NOT NULL DEFAULT '',
enabled TINYINT NOT NULL DEFAULT 1,
permission VARCHAR(64) NOT NULL DEFAULT 'enabled',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_workbench_apps_code(code),
INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order)
)`); err != nil {
return err
}
_, err := tx.Exec(
`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES
('organization','组织架构','/organization',10,'organization',1,'enabled'),
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled'),
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled')
ON DUPLICATE KEY UPDATE
name=VALUES(name),
route=VALUES(route),
sort_order=VALUES(sort_order),
unread_key=VALUES(unread_key),
updated_at=CURRENT_TIMESTAMP(3)`)
return err
}
func createIMOrganizationTables(tx *sql.Tx) error {
statements := []string{
`CREATE TABLE im_org_departments(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
parent_id VARCHAR(64) NOT NULL DEFAULT 'root',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_departments_department(department_id),
INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id),
INDEX idx_im_org_departments_name(name)
)`,
`CREATE TABLE im_org_members(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
position VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
employment_state VARCHAR(16) NOT NULL DEFAULT 'active',
contactable TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_members_department_user(department_id, user_id),
INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id),
INDEX idx_im_org_members_user(user_id),
INDEX idx_im_org_members_display_name(display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES im_org_departments(department_id) ON DELETE CASCADE
)`,
`CREATE TABLE im_org_visibility_overrides(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_visibility_user(user_id),
INDEX idx_im_org_visibility_scope(visibility_scope,state)
)`,
}
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return nil
}
func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error {
_, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_org_visibility_overrides(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
visibility_scope VARCHAR(16) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
remark VARCHAR(256) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_visibility_user(user_id),
INDEX idx_im_org_visibility_scope(visibility_scope,state)
)`)
return err
}
func createIMAttendanceTables(tx *sql.Tx) error {
for _, stmt := range imAttendanceTableStatements(false) {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return seedIMAttendanceDefaults(tx)
}
func ensureIMAttendanceTables(db *sqlx.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, stmt := range imAttendanceTableStatements(true) {
if _, err = tx.Exec(stmt); err != nil {
return err
}
}
if err = seedIMAttendanceDefaults(tx); err != nil {
return err
}
return tx.Commit()
}
func imAttendanceTableStatements(ifNotExists bool) []string {
statements := []string{
`CREATE TABLE im_attendance_rules(
id BIGINT NOT NULL AUTO_INCREMENT,
rule_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
workday TINYINT NOT NULL DEFAULT 1,
location_required TINYINT NOT NULL DEFAULT 0,
wifi_required TINYINT NOT NULL DEFAULT 0,
allow_outside_checkin TINYINT NOT NULL DEFAULT 1,
appeal_allowed TINYINT NOT NULL DEFAULT 1,
effective_from DATE,
effective_to DATE,
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_attendance_rules_rule(rule_id),
INDEX idx_im_attendance_rules_state(state,effective_from,effective_to)
)`,
`CREATE TABLE im_attendance_shifts(
id BIGINT NOT NULL AUTO_INCREMENT,
shift_id VARCHAR(64) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
work_start TIME NOT NULL,
work_end TIME NOT NULL,
checkin_start TIME NOT NULL,
checkout_end TIME NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'active',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_attendance_shifts_shift(shift_id),
INDEX idx_im_attendance_shifts_rule(rule_id,state,sort_order)
)`,
`CREATE TABLE im_attendance_records(
id BIGINT NOT NULL AUTO_INCREMENT,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
record_date DATE NOT NULL,
rule_id VARCHAR(64) NOT NULL,
shift_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'not_checked',
exceptions JSON,
appeal_status VARCHAR(32) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_attendance_records_record(record_id),
UNIQUE INDEX uk_im_attendance_records_user_date_shift(user_id,record_date,shift_id),
INDEX idx_im_attendance_records_user_id(user_id,id DESC),
INDEX idx_im_attendance_records_date_status(record_date,status,id DESC)
)`,
`CREATE TABLE im_attendance_checkins(
id BIGINT NOT NULL AUTO_INCREMENT,
checkin_id VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
record_date DATE NOT NULL,
action VARCHAR(32) NOT NULL,
rule_id VARCHAR(64) NOT NULL,
shift_id VARCHAR(64) NOT NULL,
client_time DATETIME(3) NOT NULL,
server_time DATETIME(3) NOT NULL,
result VARCHAR(32) NOT NULL,
location JSON,
wifi JSON,
device JSON,
idempotency_key VARCHAR(128) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_attendance_checkins_checkin(checkin_id),
UNIQUE INDEX uk_im_attendance_checkins_idempotency(user_id,idempotency_key),
INDEX idx_im_attendance_checkins_record(record_id,id)
)`,
`CREATE TABLE im_attendance_appeals(
id BIGINT NOT NULL AUTO_INCREMENT,
appeal_id VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
appeal_type VARCHAR(64) NOT NULL,
reason VARCHAR(512) NOT NULL,
target_action VARCHAR(32) NOT NULL DEFAULT '',
target_time DATETIME(3),
attachment_file_ids JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
submitted_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_attendance_appeals_appeal(appeal_id),
UNIQUE INDEX uk_im_attendance_appeals_idempotency(user_id,idempotency_key),
INDEX idx_im_attendance_appeals_record(record_id),
INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC)
)`,
}
if ifNotExists {
for i, stmt := range statements {
statements[i] = strings.Replace(stmt, "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)
}
}
return statements
}
func seedIMAttendanceDefaults(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO im_attendance_rules(rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed)
VALUES('att_rule_default','默认考勤规则',1,0,0,1,1)
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
return err
}
_, err := tx.Exec(`INSERT INTO im_attendance_shifts(shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,sort_order)
VALUES('shift_default','att_rule_default','默认班次','09:00:00','18:00:00','08:30:00','20:00:00',10)
ON DUPLICATE KEY UPDATE updated_at=updated_at`)
return err
}
func createIMMeetingTables(tx *sql.Tx) error {
statements := []string{
`CREATE TABLE im_meetings(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
recorder_user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
location VARCHAR(256) NOT NULL DEFAULT '',
state VARCHAR(16) NOT NULL DEFAULT 'scheduled',
reminder_minutes JSON,
idempotency_key VARCHAR(128),
version INT NOT NULL DEFAULT 1,
canceled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meetings_meeting(meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer(organizer_id, start_at),
INDEX idx_im_meetings_recorder(recorder_user_id, end_at),
INDEX idx_im_meetings_state_time(state, start_at)
)`,
`CREATE TABLE im_meeting_participants(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
response VARCHAR(16) NOT NULL DEFAULT 'pending',
response_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meeting_participants_user(meeting_id, user_id),
INDEX idx_im_meeting_participants_user_time(user_id, meeting_id),
INDEX idx_im_meeting_participants_response(meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES im_meetings(meeting_id) ON DELETE CASCADE
)`,
`CREATE TABLE im_meeting_note_files(
id BIGINT NOT NULL AUTO_INCREMENT,
note_file_id VARCHAR(64) NOT NULL,
meeting_id VARCHAR(64) NOT NULL,
file_id VARCHAR(64) NOT NULL,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
file_etag VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
deleted_by BIGINT,
deleted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
)`,
}
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return nil
}
func ensureIMMeetingNotesTables(db *sqlx.DB) error {
if db == nil {
return nil
}
var recorderExists bool
if err := db.QueryRow(`SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_meetings' AND column_name='recorder_user_id'
)`).Scan(&recorderExists); err != nil {
return err
}
if !recorderExists {
if _, err := db.Exec(`ALTER TABLE im_meetings ADD COLUMN recorder_user_id BIGINT NOT NULL DEFAULT 0 AFTER organizer_id`); err != nil {
return err
}
if _, err := db.Exec(`UPDATE im_meetings SET recorder_user_id=organizer_id WHERE recorder_user_id=0`); err != nil {
return err
}
if _, err := db.Exec(`CREATE INDEX idx_im_meetings_recorder ON im_meetings(recorder_user_id,end_at)`); err != nil && !isDupe(err) {
return err
}
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS im_meeting_note_files(
id BIGINT NOT NULL AUTO_INCREMENT,
note_file_id VARCHAR(64) NOT NULL,
meeting_id VARCHAR(64) NOT NULL,
file_id VARCHAR(64) NOT NULL,
uploader_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
file_etag VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
preview_status VARCHAR(16) NOT NULL DEFAULT 'ready',
preview_mode VARCHAR(16) NOT NULL DEFAULT 'native',
preview_file_id VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
deleted_by BIGINT,
deleted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meeting_note_files_id(note_file_id),
INDEX idx_im_meeting_note_files_file(meeting_id,file_id,status),
UNIQUE INDEX uk_im_meeting_note_files_idempotency(meeting_id,uploader_id,idempotency_key),
INDEX idx_im_meeting_note_files_meeting(meeting_id,status,id DESC),
INDEX idx_im_meeting_note_files_uploader(uploader_id,id DESC)
)`); err != nil {
return err
}
_, err := db.Exec(`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES('meeting_notes','会议笔记','/meeting-notes',35,'',1,'enabled')
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`)
return err
}
func createIMReportTables(tx *sql.Tx) error {
if _, err := tx.Exec(
`CREATE TABLE im_daily_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_date DATE NOT NULL,
content TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_daily_reports_report(report_id),
UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date),
INDEX idx_im_daily_reports_author_id(author_id, id DESC),
INDEX idx_im_daily_reports_date_status(report_date, status, id DESC)
)`); err != nil {
return err
}
_, err := tx.Exec(
`CREATE TABLE im_monthly_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_month CHAR(7) NOT NULL,
work_summary TEXT NOT NULL,
next_plan TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_monthly_reports_report(report_id),
UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month),
INDEX idx_im_monthly_reports_author_id(author_id, id DESC),
INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC)
)`)
return err
}
func createIMMailboxTables(tx *sql.Tx) error {
if _, err := tx.Exec(
`CREATE TABLE im_mailbox_config(
id BIGINT NOT NULL AUTO_INCREMENT,
enabled TINYINT NOT NULL DEFAULT 1,
allow_anonymous TINYINT NOT NULL DEFAULT 1,
required_fields JSON,
max_length INT NOT NULL DEFAULT 5000,
attachment_policy JSON,
notice_title VARCHAR(128) NOT NULL DEFAULT '',
notice_content TEXT NOT NULL,
notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content)
VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
return err
}
_, err := tx.Exec(
`CREATE TABLE im_mailbox_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
sender_id BIGINT NOT NULL,
subject VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
anonymous TINYINT NOT NULL DEFAULT 0,
attachment_file_ids JSON,
notice_version VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_mailbox_submissions_submission(submission_id),
UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key),
INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC),
INDEX idx_im_mailbox_submissions_status(status, id DESC)
)`)
return err
}
func createIMUserProductTables(tx *sql.Tx) error {
statements := []string{
`CREATE TABLE im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
birth_date DATE,
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_profiles_user(user_id)
)`,
`CREATE TABLE im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_privacy_settings_user(user_id)
)`,
`CREATE TABLE im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT,
platform VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
latest_version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64) NOT NULL,
update_type VARCHAR(16) NOT NULL DEFAULT 'none',
release_notes TEXT NOT NULL,
store_url VARCHAR(512) NOT NULL DEFAULT '',
package_url VARCHAR(512) NOT NULL DEFAULT '',
checksum VARCHAR(128) NOT NULL DEFAULT '',
remind_after_seconds INT NOT NULL DEFAULT 0,
published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel)
)`,
`CREATE TABLE im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
token VARCHAR(512) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at)
)`,
`CREATE TABLE im_risk_challenges(
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL,
notice_code VARCHAR(64) NOT NULL,
notice_params JSON,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
decision VARCHAR(16) NOT NULL DEFAULT '',
action_token_id VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_risk_challenges_challenge(challenge_id),
INDEX idx_im_risk_challenges_user_state(user_id, state, expires_at)
)`,
`CREATE TABLE im_abuse_reasons(
id BIGINT NOT NULL AUTO_INCREMENT,
target_type VARCHAR(16) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
requires_description TINYINT NOT NULL DEFAULT 0,
allows_evidence TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_abuse_reasons_type_code(target_type, reason_code),
INDEX idx_im_abuse_reasons_type_order(target_type, enabled, sort_order)
)`,
`CREATE TABLE im_abuse_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq BIGINT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
snapshot JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_abuse_reports_report(report_id),
UNIQUE INDEX uk_im_abuse_reports_idempotency(reporter_id, idempotency_key),
INDEX idx_im_abuse_reports_target(target_type, target_topic, target_seq, target_user_id),
INDEX idx_im_abuse_reports_reporter(reporter_id, id DESC)
)`,
`CREATE TABLE im_feedback_config(
id BIGINT NOT NULL AUTO_INCREMENT,
categories JSON,
max_length INT NOT NULL DEFAULT 1000,
attachment_policy JSON,
allow_contact TINYINT NOT NULL DEFAULT 1,
diagnostics_notice TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id)
)`,
`CREATE TABLE im_feedback_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
category VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(128) NOT NULL DEFAULT '',
attachment_file_ids JSON,
client_context JSON,
include_diagnostics TINYINT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_feedback_submissions_feedback(feedback_id),
UNIQUE INDEX uk_im_feedback_submissions_idempotency(user_id, idempotency_key),
INDEX idx_im_feedback_submissions_user(user_id, id DESC)
)`,
}
for _, stmt := range statements {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return nil
}
func ensureTenantRoutingColumns(a *adapter) error {
statements := []string{
"ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"UPDATE im_tenant SET db_name='tinode' WHERE code='default' AND db_name=''",
}
for _, stmt := range statements {
if _, err := a.db.Exec(stmt); err != nil && !isDuplicateColumn(err) {
return err
}
}
return nil
}
func upgradeIMMessageActions(a *adapter) error {
statements := []string{
"ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
"ALTER TABLE im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version",
"ALTER TABLE im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at",
`CREATE TABLE im_message_revisions(
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid BIGINT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY(id),
UNIQUE INDEX uk_im_message_revisions_version(topic, seqid, version)
)`,
}
for _, stmt := range statements {
if _, err := a.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
type sqlExecutor interface {
Exec(query string, args ...any) (sql.Result, error)
}
func createIMRelationTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friend_requests_request(request_id),
UNIQUE INDEX uk_im_friend_requests_idempotency(applicant_id, idempotency_key),
INDEX idx_im_friend_requests_pair(applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox(target_id, status, created_at)
)`,
`CREATE TABLE im_friendships(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status)
)`,
`CREATE TABLE im_user_blocks(
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id)
)`,
`CREATE TABLE im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq BIGINT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(favorite_id),
UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox(user_id, id DESC),
INDEX idx_im_favorites_type(user_id, item_type, id DESC)
)`,
}
for _, stmt := range statements {
if _, err := exec.Exec(stmt); err != nil {
return err
}
}
return nil
}
func createIMSecurityTables(tx *sql.Tx) error {
if _, err := tx.Exec(
`CREATE TABLE im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
action VARCHAR(64) NOT NULL DEFAULT '',
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_verification_challenge(challenge_id),
INDEX idx_im_verification_scene_phone_state(scene, phone_hash, state, expires_at)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_realname_submission(submission_id),
INDEX idx_im_realname_user_latest(user_id, created_at),
INDEX idx_im_realname_id_hash(id_number_hash)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_realname_appeals(
id BIGINT NOT NULL,
appeal_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
reason VARCHAR(500) NOT NULL DEFAULT '',
attachments_json JSON,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
review_comment VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_realname_appeals_appeal(appeal_id),
INDEX idx_im_realname_appeals_user(user_id, created_at),
INDEX idx_im_realname_appeals_status(status, created_at)
)`); err != nil {
return err
}
if _, err := tx.Exec(
`CREATE TABLE im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_action_tokens_token(token_id),
INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at)
)`); err != nil {
return err
}
_, err := tx.Exec(
`CREATE TABLE im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
policy_version INT NOT NULL DEFAULT 1,
restrictions JSON,
enabled_at DATETIME(3),
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose)
)`)
return err
}
// Create system topic 'sys'.
func createSystemTopic(tx *sql.Tx) error {
now := t.TimeNow()
query := `INSERT INTO im_topics(createdat,updatedat,state,touchedat,name,access,public)
VALUES(?,?,?,?,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')`
_, err := tx.Exec(query, now, now, t.StateOK, now)
return err
}
func addTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string, ignoreDups bool) error {
if len(tags) == 0 {
return nil
}
insert, err := tx.Prepare("INSERT INTO " + table + "(" + keyName + ",tag) VALUES(?,?)")
if err != nil {
return err
}
for _, tag := range tags {
if _, err = insert.Exec(keyVal, tag); err != nil {
if isDupe(err) {
if ignoreDups {
err = nil
continue
}
return t.ErrDuplicate
}
return err
}
}
return nil
}
func addTenantTags(tx *sqlx.Tx, tenantID t.TenantID, table, keyName string, keyVal any,
tags []string, ignoreDups bool) error {
if len(tags) == 0 {
return nil
}
insert, err := tx.Prepare("INSERT INTO " + table + "(tenant_id," + keyName + ",tag) VALUES(?,?,?)")
if err != nil {
return err
}
defer insert.Close()
for _, tag := range tags {
if _, err = insert.Exec(tenantID, keyVal, tag); err != nil {
if isDupe(err) && ignoreDups {
continue
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
return nil
}
func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) error {
if len(tags) == 0 {
return nil
}
var args []any
for _, tag := range tags {
args = append(args, tag)
}
query, args, _ := sqlx.In("DELETE FROM "+table+" WHERE "+keyName+"=? AND tag IN (?)", keyVal, args)
_, err := tx.Exec(tx.Rebind(query), args...)
return err
}
// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
// false for any other error
func (a *adapter) UserCreate(user *t.User) error {
if user == nil || strings.TrimSpace(user.Username) == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded_uid := store.DecodeUid(user.Uid())
securityID, err := nextSnowflakeID()
if err != nil {
return err
}
profileID, err := nextSnowflakeID()
if err != nil {
return err
}
var phone any
if strings.TrimSpace(user.Phone) != "" {
canonicalPhone, err := normalizeUserPhone(user.Phone)
if err != nil {
return err
}
user.Phone = canonicalPhone
phone = canonicalPhone
}
if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)",
decoded_uid,
user.Username,
user.CreatedAt,
user.UpdatedAt,
user.State,
user.Access,
phone,
common.ToJSON(user.Public),
common.ToJSON(user.Trusted)); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
if _, err = tx.Exec("INSERT INTO im_user_security(id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)",
securityID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
nickname, avatarFileID := profileSeedFromPublic(user.Public)
if _, err = tx.Exec(`INSERT IGNORE INTO im_user_profiles(id,user_id,avatar_file_id,nickname,public_account,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)`, profileID, decoded_uid, avatarFileID, nickname, user.Uid().UserId(), user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
user.Tags = nil
return tx.Commit()
}
// 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 username string
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
return t.ErrNotFound
}
return err
}
return a.AuthAddRecord(uid, "basic", username, authLvl, secret, expires)
}
// 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)
}
return a.AuthDelScheme(uid, "basic")
}
// 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)
}
return a.AuthUpdRecord(uid, "basic", username, authLvl, secret, expires)
}
// 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)
}
return a.AuthGetRecord(uid, "basic")
}
// 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)
}
return a.AuthGetUniqueRecord(login)
}
// 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
}
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(userid,authLvl,secret,expires) VALUES(?,?,?,?)",
store.DecodeUid(uid), authLvl, secret, exp); 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.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,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.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,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 = withoutUserTags(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.UserGetByCred(method, value)
}
if !a.tenantScopedDB && 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
}
return a.UserGetByCred(method, value)
}
// 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<? "+
"GROUP BY u.id, u.updatedat HAVING total=0 ORDER BY u.updatedat ASC LIMIT ?", lastUpdatedBefore, limit)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userId int64
var unused int
if err = rows.Scan(&userId, &unused); err != nil {
break
}
uids = append(uids, store.EncodeUid(userId))
}
if err == nil {
err = rows.Err()
}
return uids, err
}
func (a *adapter) topicCreate(tx *sqlx.Tx, topic *t.Topic) error {
_, err := tx.Exec("INSERT INTO im_topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
topic.CreatedAt, topic.UpdatedAt, topic.TouchedAt, topic.State, topic.Id, topic.UseBt,
store.DecodeUid(t.ParseUid(topic.Owner)), topic.Access, common.ToJSON(topic.Public), common.ToJSON(topic.Trusted),
topic.Tags, common.ToJSON(topic.Aux))
if err != nil {
return err
}
// Save topic's tags to a separate table to make topic findable.
return addTags(tx, "im_topictags", "topic", topic.Id, topic.Tags, false)
}
// TopicCreate saves topic object to database.
func (a *adapter) TopicCreate(topic *t.Topic) 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 = a.topicCreate(tx, topic)
if err != nil {
return err
}
return tx.Commit()
}
// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.
func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error {
if sub == nil {
return t.ErrMalformed
}
isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
jpriv := common.ToJSON(sub.Private)
decoded_uid := store.DecodeUid(t.ParseUid(sub.User))
existing := struct {
ID int64 `db:"id"`
DeletedAt sql.NullTime `db:"deletedat"`
}{}
err := tx.Get(&existing, "SELECT id,deletedat FROM im_subscriptions WHERE topic=? AND userid=? ORDER BY deletedat IS NULL DESC,id ASC LIMIT 1",
sub.Topic, decoded_uid)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil {
if existing.DeletedAt.Valid {
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
"delid=0,recvseqid=0,readseqid=0,private=? WHERE id=?",
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, existing.ID)
} else {
_, err = tx.Exec("UPDATE im_subscriptions SET updatedat=?,modeWant=?,modeGiven=?,private=? WHERE id=?",
sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, existing.ID)
}
if err != nil {
return err
}
if isOwner {
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE name=?", decoded_uid, sub.Topic)
}
return err
}
_, err = tx.Exec(
"INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
"VALUES(?,?,NULL,?,?,?,?,?)",
sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
if err != nil && isDupe(err) {
if undelete {
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
"delid=0,recvseqid=0,readseqid=0 WHERE topic=? AND userid=?",
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid)
} else {
_, err = tx.Exec("UPDATE im_subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+
"delid=0,recvseqid=0,readseqid=0,private=? WHERE topic=? AND userid=?",
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, sub.Topic, decoded_uid)
}
}
if err == nil && isOwner {
// Update topic owner if the subscription is with owner rights.
// Don't increment subscriber count here - it's done in TopicShare in bulk.
_, err = tx.Exec("UPDATE im_topics SET owner=? WHERE name=?", decoded_uid, sub.Topic)
}
return err
}
// TopicCreateP2P given two users creates a p2p topic.
func (a *adapter) TopicCreateP2P(initiator, invited *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()
}
}()
err = createSubscription(tx, initiator, false)
if err != nil {
return err
}
// If the second subscription exists, don't overwrite it. Just make sure it's not deleted.
err = createSubscription(tx, invited, true)
if err != nil {
return err
}
topic := &t.Topic{ObjHeader: t.ObjHeader{Id: initiator.Topic}}
topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
topic.TouchedAt = initiator.GetTouchedAt()
var existingTopicID int64
err = tx.Get(&existingTopicID, "SELECT id FROM im_topics WHERE name=? ORDER BY id ASC LIMIT 1", topic.Id)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == sql.ErrNoRows {
err = a.topicCreate(tx, topic)
if err != nil {
return err
}
} else if _, err = tx.Exec("UPDATE im_topics SET updatedat=?,touchedat=?,state=? WHERE id=?",
topic.UpdatedAt, topic.TouchedAt, t.StateOK, existingTopicID); err != nil {
return err
}
return tx.Commit()
}
// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
func (a *adapter) TopicGet(topic string) (*t.Topic, error) {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
// Fetch topic by name
var tt = new(t.Topic)
if err := a.db.GetContext(ctx, tt,
"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
"FROM im_topics WHERE name=?", topic); err != nil {
if err == sql.ErrNoRows {
// Nothing found - clear the error
err = nil
}
return nil, err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
// Topic found, get subsription count (ignoring the value set in topics.subcnt). Try both topic and channel names.
var subCnt int
if err := a.db.GetContext(ctx, &subCnt,
"SELECT COUNT(*) FROM im_subscriptions WHERE topic IN (?,?) AND deletedat IS NULL", topic, t.GrpToChn(topic)); err != nil {
return nil, err
}
if subCnt != tt.SubCnt {
// Update the topic with the correct subscription count.
tt.SubCnt = subCnt
if _, err := a.db.ExecContext(ctx, "UPDATE im_topics SET subcnt=? WHERE name=?", subCnt, topic); err != nil {
return nil, err
}
}
}
tt.Owner = common.EncodeUidString(tt.Owner).String()
tt.Public = common.FromJSON(tt.Public)
tt.Trusted = common.FromJSON(tt.Trusted)
return tt, nil
}
// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.
// Reads and denormalizes Public & Trusted values.
func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
// Fetch ALL user's subscriptions, even those which has not been modified recently.
// We are going to use these subscriptions to fetch topics and users which may have been modified recently.
q := `SELECT createdat,updatedat,deletedat,topic,delid,recvseqid,
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE userid=?`
args := []any{store.DecodeUid(uid)}
if !keepDeleted {
// Filter out deleted rows.
q += " AND deletedat IS NULL"
}
limit := 0
ims := time.Time{}
if opts != nil {
if opts.Topic != "" {
q += " AND topic=?"
args = append(args, opts.Topic)
}
// Apply the limit only when the client does not manage the cache (or cold start).
// Otherwise have to get all subscriptions and do a manual join with users/topics.
if opts.IfModifiedSince == nil {
if opts.Limit > 0 && opts.Limit < a.maxResults {
limit = opts.Limit
} else {
limit = a.maxResults
}
} else {
ims = *opts.IfModifiedSince
}
} else {
limit = a.maxResults
}
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
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()
tcat := t.GetTopicCat(tname)
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)
if uid1 == uid {
usrq = append(usrq, store.DecodeUid(uid2))
sub.SetWith(uid2.UserId())
} else {
usrq = append(usrq, store.DecodeUid(uid1))
sub.SetWith(uid1.UserId())
}
} else if tcat == t.TopicCatGrp {
// Maybe convert channel name to group topic name.
tname = t.ChnToGrp(tname)
}
// No special handling needed for 'slf', 'sys' subscriptions.
topq = append(topq, tname)
sub.Private = common.FromJSON(sub.Private)
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,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)
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.public,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,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 (?)`
q, args, _ = sqlx.In(q, 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)
join[joinOn] = sub
}
}
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 {
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.public,u.trusted,u.lastseen,u.useragent,s.private
FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.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 public, trusted any
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,
&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
break
}
sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(common.FromJSON(public))
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 := a.maxResults
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))
}
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()
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) 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))
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"+
" 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.
_, 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.CredUpsert(cred)
}
return a.CredUpsert(cred)
}
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.CredDel(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.CredConfirm(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.CredFail(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.CredGetActive(uid, method)
}
return a.CredGetActive(uid, method)
}
// 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.CredGetAll(uid, method, validatedOnly)
}
return a.CredGetAll(uid, method, validatedOnly)
}
// 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 := t.ParseUid(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 {
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<?"
args = append(args, olderThan)
}
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := tx.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var locations []string
var ids []any
for rows.Next() {
var id int
var loc string
if err = rows.Scan(&id, &loc); err != nil {
break
}
var noteRefs int
fileID := store.EncodeUid(int64(id)).String()
if err = tx.QueryRow(`SELECT COUNT(*) FROM im_meeting_note_files WHERE file_id=? AND status=? LIMIT 1`, fileID, t.MeetingNoteFileStatusActive).Scan(&noteRefs); err != nil {
break
}
if noteRefs > 0 {
continue
}
if loc != "" {
locations = append(locations, loc)
}
ids = append(ids, id)
}
if err == nil {
err = rows.Err()
}
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))
}
for _, id := range dids {
// createdat,fileid,[msgid|topic|userid]
args = append(args, now, id, linkId)
}
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()
}
}()
// 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 := t.ParseUid(fid)
if fileID.IsZero() {
return false, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var allowed int
err := a.db.GetContext(ctx, &allowed, `
SELECT COUNT(*) FROM im_fileuploads AS fu
WHERE fu.id=? AND fu.status=? AND (
fu.userid=?
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_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_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_meeting_note_files AS mnf
INNER JOIN im_meetings AS mtg
ON mtg.meeting_id=mnf.meeting_id
WHERE mnf.file_id=? AND mnf.status='active'
AND mtg.state<>?
AND mtg.end_at<=CURRENT_TIMESTAMP(3)
AND (
mtg.organizer_id=?
OR mtg.recorder_user_id=?
OR EXISTS (
SELECT 1 FROM im_meeting_participants AS mp
WHERE mp.meeting_id=mtg.meeting_id AND mp.user_id=?
)
)
)
) LIMIT 1`,
store.DecodeUid(fileID), t.UploadCompleted, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid),
fid, t.MeetingStateCanceled, store.DecodeUid(uid), store.DecodeUid(uid), store.DecodeUid(uid))
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<?", keyPrefix+"%", olderThan)
return err
}
// GetTestDB returns a currently open database connection.
func (a *adapter) GetTestDB() any {
return a.db
}
// Helper functions
// Check if MySQL error is a Error Code: 1062. Duplicate entry ... for key ...
func isDupe(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1062
}
func isMissingTable(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1146
}
func isMissingDb(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1049
}
func isUnknownColumn(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1054
}
func isDuplicateColumn(err error) bool {
if err == nil {
return false
}
myerr, ok := err.(*ms.MySQLError)
return ok && myerr.Number == 1060
}
func mysqlDefaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
// GetTestAdapter returns an adapter object. Useful for running tests.
func GetTestAdapter() *adapter {
return &adapter{}
}
func init() {
store.RegisterAdapter(&adapter{})
}