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