feat: add im tenant db routing and chapter seven flows
This commit is contained in:
@@ -215,6 +215,12 @@ type TenantAdapter interface {
|
||||
TenantGetByCode(code string) (*t.Tenant, error)
|
||||
}
|
||||
|
||||
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
|
||||
// "one tenant, one database" deployment model.
|
||||
type TenantRoutedAdapter interface {
|
||||
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
|
||||
}
|
||||
|
||||
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
|
||||
// It is separate from Adapter while the remaining persistence domains are migrated.
|
||||
type TenantAuthAdapter interface {
|
||||
@@ -281,6 +287,12 @@ type TenantBusinessAdapter interface {
|
||||
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
|
||||
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
|
||||
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
|
||||
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
|
||||
|
||||
TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error)
|
||||
TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error)
|
||||
TenantFavoriteGet(t.TenantID, t.Uid, string) (*t.FavoriteItem, error)
|
||||
TenantFavoriteDelete(t.TenantID, t.Uid, string) error
|
||||
|
||||
TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
|
||||
TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
|
||||
@@ -291,6 +303,7 @@ type TenantBusinessAdapter interface {
|
||||
TenantFileGet(t.TenantID, string) (*t.FileDef, error)
|
||||
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
|
||||
TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error
|
||||
TenantFileUserHasAccess(t.TenantID, string, t.Uid) (bool, error)
|
||||
|
||||
TenantPCacheGet(t.TenantID, string) (string, error)
|
||||
TenantPCacheUpsert(t.TenantID, string, string, bool) error
|
||||
@@ -326,8 +339,11 @@ type TenantBusinessAdapter interface {
|
||||
TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error
|
||||
TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error)
|
||||
TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, error)
|
||||
TenantFriendshipUpdateRemark(t.TenantID, t.Uid, t.Uid, string, time.Time) (*t.Friendship, error)
|
||||
TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error
|
||||
TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, error)
|
||||
TenantUserBlockList(t.TenantID, t.Uid, string, int) ([]t.BlockListItem, string, error)
|
||||
TenantUserBlockUpsert(t.TenantID, t.Uid, t.Uid, time.Time) (*t.UserBlock, error)
|
||||
TenantUserBlockDelete(t.TenantID, t.Uid, t.Uid) error
|
||||
TenantUserSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.User, string, error)
|
||||
}
|
||||
|
||||
+261
-24
@@ -13,11 +13,13 @@ import (
|
||||
"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"
|
||||
@@ -25,9 +27,10 @@ import (
|
||||
|
||||
// adapter holds MySQL connection data.
|
||||
type adapter struct {
|
||||
db *sqlx.DB
|
||||
dsn string
|
||||
dbName string
|
||||
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
|
||||
@@ -38,6 +41,12 @@ type adapter struct {
|
||||
sqlTimeout time.Duration
|
||||
// DB transaction timeout.
|
||||
txTimeout time.Duration
|
||||
|
||||
baseConfig *configType
|
||||
tenantDBRouting bool
|
||||
tenantRegistryDB string
|
||||
tenantAdapters map[t.TenantID]*adapter
|
||||
tenantMu sync.Mutex
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -78,6 +87,11 @@ type configType struct {
|
||||
// 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) {
|
||||
@@ -94,6 +108,44 @@ func (a *adapter) getContextForTx() (context.Context, context.CancelFunc) {
|
||||
return context.Background(), nil
|
||||
}
|
||||
|
||||
func openMySQLDB(config configType, dsn, dbName string) (*sqlx.DB, string, error) {
|
||||
if strings.TrimSpace(dsn) != "" {
|
||||
cfg, err := ms.ParseDSN(strings.TrimPrefix(dsn, "mysql://"))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if dbName != "" {
|
||||
cfg.DBName = dbName
|
||||
}
|
||||
dsn = cfg.FormatDSN()
|
||||
dbName = cfg.DBName
|
||||
} else {
|
||||
cfg := config.Config
|
||||
if dbName != "" {
|
||||
cfg.DBName = dbName
|
||||
}
|
||||
if cfg.DBName == "" {
|
||||
cfg.DBName = defaultDatabase
|
||||
}
|
||||
dsn = cfg.FormatDSN()
|
||||
dbName = cfg.DBName
|
||||
}
|
||||
db, err := sqlx.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if config.MaxOpenConns > 0 {
|
||||
db.SetMaxOpenConns(config.MaxOpenConns)
|
||||
}
|
||||
if config.MaxIdleConns > 0 {
|
||||
db.SetMaxIdleConns(config.MaxIdleConns)
|
||||
}
|
||||
if config.ConnMaxLifetime > 0 {
|
||||
db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
|
||||
}
|
||||
return db, dbName, nil
|
||||
}
|
||||
|
||||
// Open initializes database session
|
||||
func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
if a.db != nil {
|
||||
@@ -139,6 +191,11 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
if a.dbName == "" {
|
||||
a.dbName = defaultDatabase
|
||||
}
|
||||
config.Config.DBName = a.dbName
|
||||
a.baseConfig = &config
|
||||
a.tenantDBRouting = config.TenantDBRouting
|
||||
a.tenantRegistryDB = strings.TrimSpace(config.TenantRegistryDB)
|
||||
a.tenantAdapters = make(map[t.TenantID]*adapter)
|
||||
|
||||
if a.maxResults <= 0 {
|
||||
a.maxResults = defaultMaxResults
|
||||
@@ -149,7 +206,7 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
}
|
||||
|
||||
// This just initializes the driver but does not open the network connection.
|
||||
a.db, err = sqlx.Open("mysql", a.dsn)
|
||||
a.db, a.dbName, err = openMySQLDB(config, a.dsn, a.dbName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -162,20 +219,20 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
err = nil
|
||||
}
|
||||
if err == nil {
|
||||
if config.MaxOpenConns > 0 {
|
||||
a.db.SetMaxOpenConns(config.MaxOpenConns)
|
||||
}
|
||||
if config.MaxIdleConns > 0 {
|
||||
a.db.SetMaxIdleConns(config.MaxIdleConns)
|
||||
}
|
||||
if config.ConnMaxLifetime > 0 {
|
||||
a.db.SetConnMaxLifetime(time.Duration(config.ConnMaxLifetime) * time.Second)
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -183,8 +240,26 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
|
||||
// Close closes the underlying database connection
|
||||
func (a *adapter) Close() error {
|
||||
var err error
|
||||
a.tenantMu.Lock()
|
||||
for tenantID, tenantAdp := range a.tenantAdapters {
|
||||
if tenantAdp != nil && tenantAdp.db != nil {
|
||||
if closeErr := tenantAdp.db.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}
|
||||
delete(a.tenantAdapters, tenantID)
|
||||
}
|
||||
a.tenantMu.Unlock()
|
||||
if a.registryDB != nil {
|
||||
if closeErr := a.registryDB.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
a.registryDB = nil
|
||||
}
|
||||
if a.db != nil {
|
||||
err = a.db.Close()
|
||||
if closeErr := a.db.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
a.db = nil
|
||||
a.version = -1
|
||||
}
|
||||
@@ -285,18 +360,112 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) {
|
||||
}
|
||||
|
||||
var tenant t.Tenant
|
||||
err := a.db.GetContext(ctx, &tenant,
|
||||
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
|
||||
db := a.db
|
||||
if a.registryDB != nil {
|
||||
db = a.registryDB
|
||||
}
|
||||
err := db.GetContext(ctx, &tenant,
|
||||
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
|
||||
state, created_at, created_by, updated_at, updated_by
|
||||
FROM im_tenant WHERE code=? LIMIT 1`, code)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if isUnknownColumn(err) {
|
||||
err = db.GetContext(ctx, &tenant,
|
||||
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
|
||||
FROM im_tenant WHERE code=? LIMIT 1`, code)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) {
|
||||
if !a.tenantDBRouting || tenantID == defaultTenantID {
|
||||
return a, a, nil
|
||||
}
|
||||
a.tenantMu.Lock()
|
||||
if tenantAdp := a.tenantAdapters[tenantID]; tenantAdp != nil {
|
||||
a.tenantMu.Unlock()
|
||||
return tenantAdp, tenantAdp, nil
|
||||
}
|
||||
a.tenantMu.Unlock()
|
||||
|
||||
tenant, err := a.tenantByID(tenantID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dbName := strings.TrimSpace(tenant.DBName)
|
||||
dsn := strings.TrimSpace(tenant.DBDSN)
|
||||
if dbName == "" && dsn == "" {
|
||||
return a, a, nil
|
||||
}
|
||||
if a.baseConfig == nil {
|
||||
return nil, nil, t.ErrFailed
|
||||
}
|
||||
db, resolvedDBName, err := openMySQLDB(*a.baseConfig, mysqlDefaultString(dsn, a.dsn), dbName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
tenantAdp := &adapter{
|
||||
db: db,
|
||||
dsn: mysqlDefaultString(dsn, a.dsn),
|
||||
dbName: resolvedDBName,
|
||||
maxResults: a.maxResults,
|
||||
maxMessageResults: a.maxMessageResults,
|
||||
version: a.version,
|
||||
sqlTimeout: a.sqlTimeout,
|
||||
txTimeout: a.txTimeout,
|
||||
baseConfig: a.baseConfig,
|
||||
}
|
||||
a.tenantMu.Lock()
|
||||
if existing := a.tenantAdapters[tenantID]; existing != nil {
|
||||
a.tenantMu.Unlock()
|
||||
db.Close()
|
||||
return existing, existing, nil
|
||||
}
|
||||
a.tenantAdapters[tenantID] = tenantAdp
|
||||
a.tenantMu.Unlock()
|
||||
return tenantAdp, tenantAdp, nil
|
||||
}
|
||||
|
||||
func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) {
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
db := a.db
|
||||
if a.registryDB != nil {
|
||||
db = a.registryDB
|
||||
}
|
||||
var tenant t.Tenant
|
||||
err := db.GetContext(ctx, &tenant,
|
||||
`SELECT id, code, name, tenant_desc, IFNULL(db_name,'') AS db_name, IFNULL(db_dsn,'') AS db_dsn,
|
||||
state, created_at, created_by, updated_at, updated_by
|
||||
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if isUnknownColumn(err) {
|
||||
err = db.GetContext(ctx, &tenant,
|
||||
`SELECT id, code, name, tenant_desc, state, created_at, created_by, updated_at, updated_by
|
||||
FROM im_tenant WHERE id=? LIMIT 1`, tenantID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !tenant.IsActive() {
|
||||
return nil, t.ErrPermissionDenied
|
||||
}
|
||||
return &tenant, nil
|
||||
}
|
||||
|
||||
// CreateDb initializes the storage.
|
||||
func (a *adapter) CreateDb(reset bool) error {
|
||||
var err error
|
||||
@@ -348,6 +517,8 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
tenant_desc VARCHAR(256),
|
||||
db_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
state SMALLINT NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
@@ -407,8 +578,8 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
||||
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -997,10 +1168,12 @@ func (a *adapter) UpgradeDb() error {
|
||||
if _, err := a.db.Exec(
|
||||
`CREATE TABLE im_tenant(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
tenant_desc VARCHAR(256),
|
||||
state SMALLINT NOT NULL,
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
tenant_desc VARCHAR(256),
|
||||
db_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
state SMALLINT NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
@@ -1030,8 +1203,8 @@ func (a *adapter) UpgradeDb() error {
|
||||
}
|
||||
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
||||
`INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1162,6 +1335,10 @@ func (a *adapter) UpgradeDb() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureTenantRoutingColumns(a); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if a.version != adpVersion {
|
||||
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
||||
". DB is still at " + strconv.Itoa(a.version))
|
||||
@@ -1191,6 +1368,20 @@ func createIMNotificationTables(tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureTenantRoutingColumns(a *adapter) error {
|
||||
statements := []string{
|
||||
"ALTER TABLE im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
|
||||
"ALTER TABLE im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
|
||||
"UPDATE im_tenant SET db_name='tinode' WHERE code='default' AND db_name=''",
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := a.db.Exec(stmt); err != nil && !isDuplicateColumn(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upgradeIMMessageActions(a *adapter) error {
|
||||
statements := []string{
|
||||
"ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id",
|
||||
@@ -1272,6 +1463,29 @@ func createIMRelationTables(exec sqlExecutor) error {
|
||||
INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id),
|
||||
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE im_favorites(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
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 INT 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(tenant_id, favorite_id),
|
||||
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
|
||||
UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key),
|
||||
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
|
||||
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
|
||||
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := exec.Exec(stmt); err != nil {
|
||||
@@ -4608,6 +4822,29 @@ func isMissingDb(err error) bool {
|
||||
return ok && myerr.Number == 1049
|
||||
}
|
||||
|
||||
func isUnknownColumn(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
myerr, ok := err.(*ms.MySQLError)
|
||||
return ok && myerr.Number == 1054
|
||||
}
|
||||
|
||||
func isDuplicateColumn(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
myerr, ok := err.(*ms.MySQLError)
|
||||
return ok && myerr.Number == 1060
|
||||
}
|
||||
|
||||
func mysqlDefaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// GetTestAdapter returns an adapter object. Useful for running tests.
|
||||
func GetTestAdapter() *adapter {
|
||||
return &adapter{}
|
||||
|
||||
@@ -19,6 +19,8 @@ CREATE TABLE im_tenant(
|
||||
code VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
tenant_desc VARCHAR(256),
|
||||
db_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
state SMALLINT NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
created_by BIGINT NOT NULL,
|
||||
@@ -72,8 +74,8 @@ CREATE TABLE im_verification_codes(
|
||||
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,1,0,0);
|
||||
INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0);
|
||||
|
||||
|
||||
CREATE TABLE kvmeta(
|
||||
@@ -246,6 +248,31 @@ CREATE TABLE im_user_blocks(
|
||||
CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE im_favorites(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
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 INT 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(tenant_id, favorite_id),
|
||||
UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq),
|
||||
UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key),
|
||||
INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC),
|
||||
INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC),
|
||||
CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
# Indexed user tags.
|
||||
CREATE TABLE usertags(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -1549,6 +1549,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
|
||||
if err := validTenant(id); err != nil || topic == "" || uid.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
where := []string{"m.tenant_id=?", "m.topic=?", "m.delid=0"}
|
||||
args := []any{id, topic}
|
||||
if cursor != "" {
|
||||
parsed, err := strconv.Atoi(cursor)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where = append(where, "m.seqid<?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
queryText = strings.TrimSpace(queryText)
|
||||
if queryText != "" {
|
||||
where = append(where, "CAST(m.content AS CHAR) LIKE ?")
|
||||
args = append(args, "%"+queryText+"%")
|
||||
}
|
||||
if messageType != "" && messageType != "all" {
|
||||
where = append(where, "(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type'))=? OR JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.x-im-type'))=?)")
|
||||
args = append(args, messageType, messageType)
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
queryArgs := append([]any{decodeUID(uid)}, args...)
|
||||
queryArgs = append(queryArgs, limit+1)
|
||||
rows, err := a.db.QueryxContext(ctx,
|
||||
"SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
|
||||
"FROM messages AS m LEFT JOIN dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+
|
||||
"AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
|
||||
queryArgs...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
msgs := make([]t.Message, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
var msg t.Message
|
||||
if err = rows.StructScan(&msg); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
msg.From = common.EncodeUidString(msg.From).String()
|
||||
msg.Head = kvMapFromJSON(msg.Head)
|
||||
msg.Content = common.FromJSON(msg.Content)
|
||||
if len(msgs) == limit {
|
||||
nextCursor = strconv.Itoa(msg.SeqId)
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return msgs, nextCursor, nil
|
||||
}
|
||||
|
||||
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) {
|
||||
row := struct {
|
||||
t.Message
|
||||
@@ -1577,6 +1640,146 @@ func messageVersion(version int) int {
|
||||
return version
|
||||
}
|
||||
|
||||
func kvMapFromJSON(value any) t.KVMap {
|
||||
switch parsed := common.FromJSON(value).(type) {
|
||||
case map[string]any:
|
||||
return t.KVMap(parsed)
|
||||
case t.KVMap:
|
||||
return parsed
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
|
||||
if err := validTenant(id); err != nil || fav == nil || fav.TenantID != id || fav.UserID.IsZero() || fav.FavoriteID == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_favorites(tenant_id,favorite_id,user_id,item_type,source_topic,source_seq,snapshot,attachment_refs,availability,idempotency_key,collected_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
|
||||
id, fav.FavoriteID, decodeUID(fav.UserID), fav.ItemType, fav.SourceTopic, fav.SourceSeq, common.ToJSON(fav.Snapshot), common.ToJSON(fav.AttachmentRefs), fav.Availability, fav.IdempotencyKey, fav.CollectedAt, fav.CreatedAt, fav.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
where := []string{"tenant_id=?", "user_id=?"}
|
||||
args := []any{id, decodeUID(uid)}
|
||||
if cursor != "" {
|
||||
parsed, err := strconv.ParseInt(cursor, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where = append(where, "id<?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
if itemType != "" && itemType != "all" {
|
||||
where = append(where, "item_type=?")
|
||||
args = append(args, itemType)
|
||||
}
|
||||
if queryText = strings.TrimSpace(queryText); queryText != "" {
|
||||
where = append(where, "CAST(snapshot AS CHAR) LIKE ?")
|
||||
args = append(args, "%"+queryText+"%")
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE "+strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]t.FavoriteItem, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
item, err := scanFavorite(rows)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(items) == limit {
|
||||
nextCursor = strconv.FormatInt(item.ID, 10)
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return items, nextCursor, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) (*t.FavoriteItem, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
item, err := scanFavorite(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID string) error {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, "DELETE FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanFavorite(rows *sqlx.Rows) (t.FavoriteItem, error) {
|
||||
row := struct {
|
||||
t.FavoriteItem
|
||||
UserID int64 `db:"user_id"`
|
||||
}{}
|
||||
if err := rows.StructScan(&row); err != nil {
|
||||
return t.FavoriteItem{}, err
|
||||
}
|
||||
item := row.FavoriteItem
|
||||
item.UserID = store.EncodeUid(row.UserID)
|
||||
item.Snapshot = common.FromJSON(item.Snapshot)
|
||||
item.AttachmentRefs = common.FromJSON(item.AttachmentRefs)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
|
||||
if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) {
|
||||
return t.ErrMalformed
|
||||
@@ -1954,6 +2157,57 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID,
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) (bool, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return false, t.ErrMalformed
|
||||
}
|
||||
fileID := t.ParseUid(fid)
|
||||
if fileID.IsZero() {
|
||||
return false, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var allowed int
|
||||
err := a.db.GetContext(ctx, &allowed, `
|
||||
SELECT COUNT(*) FROM fileuploads AS fu
|
||||
WHERE fu.tenant_id=? AND fu.id=? AND fu.status=? AND (
|
||||
fu.userid=?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM filemsglinks AS fml
|
||||
WHERE fml.tenant_id=fu.tenant_id AND fml.fileid=fu.id AND fml.userid=?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM filemsglinks AS fml
|
||||
INNER JOIN subscriptions AS s
|
||||
ON s.tenant_id=fml.tenant_id AND s.topic=fml.topic AND s.userid=?
|
||||
WHERE fml.tenant_id=fu.tenant_id AND 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 filemsglinks AS fml
|
||||
INNER JOIN messages AS m
|
||||
ON m.tenant_id=fml.tenant_id AND m.id=fml.msgid
|
||||
INNER JOIN subscriptions AS s
|
||||
ON s.tenant_id=m.tenant_id AND s.topic=m.topic AND s.userid=?
|
||||
WHERE fml.tenant_id=fu.tenant_id AND 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 dellog AS d
|
||||
WHERE d.tenant_id=m.tenant_id AND d.topic=m.topic AND d.deletedfor=?
|
||||
AND d.low<=m.seqid AND (d.hi=0 OR d.hi>m.seqid)
|
||||
)
|
||||
)
|
||||
) LIMIT 1`,
|
||||
id, decodeUID(fileID), t.UploadCompleted, decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid), decodeUID(uid))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return allowed > 0, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantPCacheGet(id t.TenantID, key string) (string, error) {
|
||||
if err := validTenant(id); err != nil {
|
||||
return "", err
|
||||
@@ -2998,6 +3252,26 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) {
|
||||
if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() || len([]rune(remark)) > 64 {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx,
|
||||
"UPDATE im_friendships SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?",
|
||||
remark, now, id, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return a.TenantFriendshipGet(id, userID, peerID)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, now time.Time) error {
|
||||
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
|
||||
return t.ErrMalformed
|
||||
@@ -3044,6 +3318,79 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) {
|
||||
if err := validTenant(id); err != nil || blockerID.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
where := []string{"b.tenant_id=?", "b.blocker_id=?"}
|
||||
args := []any{id, decodeUID(blockerID)}
|
||||
if cursor != "" {
|
||||
parsed, err := strconv.ParseInt(cursor, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where = append(where, "b.blocked_id<?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT b.*,u.public,u.trusted
|
||||
FROM im_user_blocks AS b
|
||||
LEFT JOIN users AS u ON u.tenant_id=b.tenant_id AND u.id=b.blocked_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY b.blocked_id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]t.BlockListItem, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
TenantID t.TenantID `db:"tenant_id"`
|
||||
BlockerID int64 `db:"blocker_id"`
|
||||
BlockedID int64 `db:"blocked_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
Public any
|
||||
Trusted any
|
||||
}{}
|
||||
if err = rows.StructScan(&row); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(items) == limit {
|
||||
nextCursor = strconv.FormatInt(row.BlockedID, 10)
|
||||
continue
|
||||
}
|
||||
blockedID := store.EncodeUid(row.BlockedID)
|
||||
items = append(items, t.BlockListItem{
|
||||
Block: t.UserBlock{
|
||||
TenantID: row.TenantID,
|
||||
BlockerID: store.EncodeUid(row.BlockerID),
|
||||
BlockedID: blockedID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
},
|
||||
User: &t.User{
|
||||
ObjHeader: t.ObjHeader{Id: blockedID.String()},
|
||||
TenantID: row.TenantID,
|
||||
Public: common.FromJSON(row.Public),
|
||||
Trusted: common.FromJSON(row.Trusted),
|
||||
},
|
||||
})
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return items, nextCursor, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Uid, now time.Time) (*t.UserBlock, error) {
|
||||
if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() {
|
||||
return nil, t.ErrMalformed
|
||||
@@ -3075,6 +3422,85 @@ func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Ui
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searchType, cursor string, limit int) ([]t.User, string, error) {
|
||||
if err := validTenant(id); err != nil || viewer.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
queryText = strings.TrimSpace(queryText)
|
||||
if queryText == "" {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
where := []string{"u.tenant_id=?", "u.id<>?", "u.state=?"}
|
||||
args := []any{id, decodeUID(viewer), t.StateOK}
|
||||
if cursor != "" {
|
||||
parsed, err := strconv.ParseInt(cursor, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where = append(where, "u.id>?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
join := ""
|
||||
switch searchType {
|
||||
case "", "account":
|
||||
like := "%" + queryText + "%"
|
||||
join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id"
|
||||
where = append(where, "(ut.tag LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)")
|
||||
args = append(args, like, like, userIDNumericArg(queryText))
|
||||
case "exact_account":
|
||||
join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id"
|
||||
where = append(where, "(ut.tag=? OR u.id=?)")
|
||||
args = append(args, queryText, userIDNumericArg(queryText))
|
||||
case "phone":
|
||||
where = append(where, `EXISTS (
|
||||
SELECT 1 FROM credentials AS c
|
||||
WHERE c.tenant_id=u.tenant_id AND c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true
|
||||
)`)
|
||||
args = append(args, queryText)
|
||||
default:
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.* FROM users AS u `+join+`
|
||||
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make([]t.User, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
var user t.User
|
||||
if err = rows.StructScan(&user); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
scanTenantUser(&user)
|
||||
if len(users) == limit {
|
||||
nextCursor = strconv.FormatInt(decodeUID(user.Uid()), 10)
|
||||
continue
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return users, nextCursor, nil
|
||||
}
|
||||
|
||||
func userIDNumericArg(value string) int64 {
|
||||
if uid := t.ParseUserId(value); !uid.IsZero() {
|
||||
return decodeUID(uid)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *adapter) friendRequestByRequestID(ctx context.Context, tenantID t.TenantID, requestID string) (*t.FriendRequest, error) {
|
||||
return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID)
|
||||
}
|
||||
|
||||
+55
-7
@@ -18,6 +18,7 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -131,12 +132,6 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodHead {
|
||||
wrt.WriteHeader(http.StatusOK)
|
||||
logs.Info.Println("media serve: completed", req.Method, "uid=", uid)
|
||||
return
|
||||
}
|
||||
|
||||
fd, rsc, err := mh.Download(tenantID, req.URL.String())
|
||||
if err != nil {
|
||||
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
|
||||
@@ -145,7 +140,15 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
|
||||
|
||||
defer rsc.Close()
|
||||
|
||||
if err = authorizeLargeFileDownload(tenantID, uid, req.URL.String(), fd); err != nil {
|
||||
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
|
||||
return
|
||||
}
|
||||
|
||||
wrt.Header().Set("Content-Type", fd.MimeType)
|
||||
if fd.ETag != "" {
|
||||
wrt.Header().Set("ETag", strconv.Quote(fd.ETag))
|
||||
}
|
||||
asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt"))
|
||||
// Force download for html files as a security measure.
|
||||
asAttachment = asAttachment ||
|
||||
@@ -159,7 +162,7 @@ func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) {
|
||||
strings.HasPrefix(fd.MimeType, "multipart/") ||
|
||||
strings.HasPrefix(fd.MimeType, "text/")
|
||||
if asAttachment {
|
||||
wrt.Header().Set("Content-Disposition", "attachment")
|
||||
wrt.Header().Set("Content-Disposition", contentDispositionAttachment(fd))
|
||||
}
|
||||
|
||||
http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc)
|
||||
@@ -415,6 +418,11 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
|
||||
|
||||
defer rsc.Close()
|
||||
|
||||
if err = authorizeLargeFileDownload(tenantID, uid, req.GetUri(), fd); err != nil {
|
||||
writeResponse(decodeStoreError(err, msgID, now, nil), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
resp.Code = http.StatusOK
|
||||
resp.Text = http.StatusText(http.StatusOK)
|
||||
resp.Meta.Name = fd.Location
|
||||
@@ -445,6 +453,46 @@ func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_Larg
|
||||
return err
|
||||
}
|
||||
|
||||
func authorizeLargeFileDownload(tenantID types.TenantID, uid types.Uid, rawURL string, fd *types.FileDef) error {
|
||||
if fd == nil {
|
||||
return types.ErrNotFound
|
||||
}
|
||||
fid := fd.Uid()
|
||||
if fid.IsZero() && rawURL != "" && store.Store != nil {
|
||||
fid = store.Store.GetMediaHandler().GetIdFromUrl(rawURL)
|
||||
}
|
||||
if fid.IsZero() || fd.TenantID != tenantID || fd.Status != types.UploadCompleted {
|
||||
return types.ErrNotFound
|
||||
}
|
||||
allowed, err := store.Files.UserHasAccess(tenantID, fid.String(), uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return types.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contentDispositionAttachment(fd *types.FileDef) string {
|
||||
name := strings.TrimSpace(filepath.Base(fd.Location))
|
||||
if name == "." || name == "/" || name == string(filepath.Separator) {
|
||||
name = fd.Uid().String()
|
||||
}
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r < 32 || r == 127 || r == '"' || r == '\\' || r == '/' || r == ':':
|
||||
return -1
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
if clean == "" {
|
||||
clean = fd.Uid().String()
|
||||
}
|
||||
return mime.FormatMediaType("attachment", map[string]string{"filename": clean})
|
||||
}
|
||||
|
||||
// LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP.
|
||||
func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error {
|
||||
now := types.TimeNow()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/mock_store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestAuthorizeLargeFileDownloadDenied(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldFiles := store.Files
|
||||
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
||||
store.Files = files
|
||||
defer func() { store.Files = oldFiles }()
|
||||
|
||||
fd := &types.FileDef{
|
||||
ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
|
||||
TenantID: 7,
|
||||
Status: types.UploadCompleted,
|
||||
MimeType: "text/plain",
|
||||
Location: "notes.txt",
|
||||
}
|
||||
files.EXPECT().UserHasAccess(types.TenantID(7), types.Uid(100).String(), types.Uid(42)).Return(false, nil)
|
||||
if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrPermissionDenied {
|
||||
t.Fatalf("expected permission denied, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentDispositionAttachmentSanitizesName(t *testing.T) {
|
||||
fd := &types.FileDef{
|
||||
ObjHeader: types.ObjHeader{Id: types.Uid(100).String()},
|
||||
Location: `../bad"name.txt`,
|
||||
}
|
||||
got := contentDispositionAttachment(fd)
|
||||
if got != `attachment; filename=badname.txt` {
|
||||
t.Fatalf("unexpected content disposition: %s", got)
|
||||
}
|
||||
}
|
||||
+2652
-5
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -782,6 +782,7 @@ func main() {
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/security/urls/check", serveIMSecurityURLCheck)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName)
|
||||
@@ -792,11 +793,19 @@ func main() {
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/favorites/", serveIMFavoriteResource)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/groups", serveIMGroups)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/me/qrcodes", serveIMMeQRCodes)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/qrcodes/resolve", serveIMQRCodesResolve)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/users/search", serveIMUserSearch)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/users/", serveIMUserResource)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/blocks", serveIMBlocks)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource)
|
||||
|
||||
@@ -1144,6 +1144,22 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetDeleted(tenantID, top
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeleted", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).GetDeleted), tenantID, topic, forUser, opt)
|
||||
}
|
||||
|
||||
// Search mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Search", tenantID, topic, forUser, query, messageType, cursor, limit)
|
||||
ret0, _ := ret[0].([]types.Message)
|
||||
ret1, _ := ret[1].(string)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// Search indicates an expected call of Search.
|
||||
func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic, forUser, query, messageType, cursor, limit interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Search), tenantID, topic, forUser, query, messageType, cursor, limit)
|
||||
}
|
||||
|
||||
// GetOne mocks base method.
|
||||
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1342,6 +1358,21 @@ func (mr *MockFilePersistenceInterfaceMockRecorder) LinkAttachments(tenantID, to
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LinkAttachments", reflect.TypeOf((*MockFilePersistenceInterface)(nil).LinkAttachments), tenantID, topic, msgId, attachments)
|
||||
}
|
||||
|
||||
// UserHasAccess mocks base method.
|
||||
func (m *MockFilePersistenceInterface) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UserHasAccess", tenantID, fid, uid)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UserHasAccess indicates an expected call of UserHasAccess.
|
||||
func (mr *MockFilePersistenceInterfaceMockRecorder) UserHasAccess(tenantID, fid, uid interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserHasAccess", reflect.TypeOf((*MockFilePersistenceInterface)(nil).UserHasAccess), tenantID, fid, uid)
|
||||
}
|
||||
|
||||
// StartUpload mocks base method.
|
||||
func (m *MockFilePersistenceInterface) StartUpload(tenantID types.TenantID, fd *types.FileDef) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -149,6 +149,13 @@ func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, erro
|
||||
if !tenantID.IsValid() {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
if routed, ok := adp.(adapter.TenantRoutedAdapter); ok {
|
||||
_, authAdp, err := routed.TenantAdapterFor(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authAdp, nil
|
||||
}
|
||||
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
|
||||
if !ok {
|
||||
return nil, types.ErrUnsupported
|
||||
@@ -160,6 +167,17 @@ func tenantBusinessAdapter(tenantID types.TenantID) (adapter.TenantBusinessAdapt
|
||||
if !tenantID.IsValid() {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
if routed, ok := adp.(adapter.TenantRoutedAdapter); ok {
|
||||
businessAdp, _, err := routed.TenantAdapterFor(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !businessAdp.TenantBusinessReady() {
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
activeTenantIDs.Store(tenantID, struct{}{})
|
||||
return businessAdp, nil
|
||||
}
|
||||
tenantAdp, ok := adp.(adapter.TenantBusinessAdapter)
|
||||
if !ok {
|
||||
return nil, types.ErrUnsupported
|
||||
@@ -960,6 +978,7 @@ type MessagesPersistenceInterface interface {
|
||||
DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error
|
||||
GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error)
|
||||
GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error)
|
||||
Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error)
|
||||
}
|
||||
|
||||
// messagesMapper is a concrete type implementing MessagesPersistenceInterface.
|
||||
@@ -1003,14 +1022,75 @@ type RelationsPersistenceInterface interface {
|
||||
GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error)
|
||||
CanSendP2P(tenantID types.TenantID, userID, peerID types.Uid) (bool, string, error)
|
||||
BlockUser(tenantID types.TenantID, blockerID, blockedID types.Uid, now time.Time) (*types.UserBlock, error)
|
||||
ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error)
|
||||
UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error
|
||||
DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error
|
||||
UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error)
|
||||
SearchUsers(tenantID types.TenantID, viewer types.Uid, query, searchType, cursor string, limit int) ([]types.UserSearchItem, string, error)
|
||||
}
|
||||
|
||||
type relationsMapper struct{}
|
||||
|
||||
var Relations RelationsPersistenceInterface
|
||||
|
||||
type FavoritesPersistenceInterface interface {
|
||||
Create(tenantID types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error)
|
||||
List(tenantID types.TenantID, uid types.Uid, itemType, query, cursor string, limit int) ([]types.FavoriteItem, string, error)
|
||||
Get(tenantID types.TenantID, uid types.Uid, favoriteID string) (*types.FavoriteItem, error)
|
||||
Delete(tenantID types.TenantID, uid types.Uid, favoriteID string) error
|
||||
}
|
||||
|
||||
type favoritesMapper struct{}
|
||||
|
||||
var Favorites FavoritesPersistenceInterface
|
||||
|
||||
func (favoritesMapper) Create(tenantID types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) {
|
||||
if item == nil || item.TenantID != tenantID || item.UserID.IsZero() || item.FavoriteID == "" {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantFavoriteCreate(tenantID, item)
|
||||
}
|
||||
|
||||
func (favoritesMapper) List(tenantID types.TenantID, uid types.Uid, itemType, query, cursor string, limit int) ([]types.FavoriteItem, string, error) {
|
||||
if uid.IsZero() {
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tenantAdp.TenantFavoriteList(tenantID, uid, itemType, query, cursor, limit)
|
||||
}
|
||||
|
||||
func (favoritesMapper) Get(tenantID types.TenantID, uid types.Uid, favoriteID string) (*types.FavoriteItem, error) {
|
||||
if uid.IsZero() || strings.TrimSpace(favoriteID) == "" {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantFavoriteGet(tenantID, uid, favoriteID)
|
||||
}
|
||||
|
||||
func (favoritesMapper) Delete(tenantID types.TenantID, uid types.Uid, favoriteID string) error {
|
||||
if uid.IsZero() || strings.TrimSpace(favoriteID) == "" {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantFavoriteDelete(tenantID, uid, favoriteID)
|
||||
}
|
||||
|
||||
func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) {
|
||||
if req == nil || req.TenantID != tenantID || req.ApplicantID.IsZero() || req.TargetID.IsZero() || req.ApplicantID == req.TargetID {
|
||||
return nil, types.ErrMalformed
|
||||
@@ -1111,6 +1191,20 @@ func (relationsMapper) BlockUser(tenantID types.TenantID, blockerID, blockedID t
|
||||
return tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now)
|
||||
}
|
||||
|
||||
func (relationsMapper) ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error) {
|
||||
if blockerID.IsZero() {
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tenantAdp.TenantUserBlockList(tenantID, blockerID, cursor, limit)
|
||||
}
|
||||
|
||||
func (relationsMapper) UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error {
|
||||
if blockerID.IsZero() || blockedID.IsZero() || blockerID == blockedID {
|
||||
return types.ErrMalformed
|
||||
@@ -1133,6 +1227,68 @@ func (relationsMapper) DeleteFriend(tenantID types.TenantID, userID, peerID type
|
||||
return tenantAdp.TenantFriendshipDeletePair(tenantID, userID, peerID, now)
|
||||
}
|
||||
|
||||
func (relationsMapper) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) {
|
||||
if userID.IsZero() || peerID.IsZero() || userID == peerID || len([]rune(remark)) > 64 {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantFriendshipUpdateRemark(tenantID, userID, peerID, remark, now)
|
||||
}
|
||||
|
||||
func (relationsMapper) SearchUsers(tenantID types.TenantID, viewer types.Uid, query, searchType, cursor string, limit int) ([]types.UserSearchItem, string, error) {
|
||||
if viewer.IsZero() || strings.TrimSpace(query) == "" {
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
switch searchType {
|
||||
case "", "account", "exact_account", "phone":
|
||||
default:
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
users, nextCursor, err := tenantAdp.TenantUserSearch(tenantID, viewer, query, searchType, cursor, limit)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
items := make([]types.UserSearchItem, 0, len(users))
|
||||
for i := range users {
|
||||
uid := users[i].Uid()
|
||||
ctx, err := relationshipContext(tenantAdp, tenantID, viewer, uid, viewer.P2PName(uid))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
items = append(items, types.UserSearchItem{
|
||||
User: &users[i],
|
||||
Relationship: ctx.Relationship,
|
||||
RequestID: ctx.RequestID,
|
||||
CanAdd: ctx.Relationship == types.RelationshipNone,
|
||||
Action: relationshipAction(ctx.Relationship),
|
||||
})
|
||||
}
|
||||
return items, nextCursor, nil
|
||||
}
|
||||
|
||||
func relationshipAction(relationship string) string {
|
||||
switch relationship {
|
||||
case types.RelationshipFriend:
|
||||
return "chat"
|
||||
case types.RelationshipPendingInbound, types.RelationshipPendingOutbound:
|
||||
return "pending"
|
||||
case types.RelationshipBlockedByMe, types.RelationshipBlockedByPeer:
|
||||
return "none"
|
||||
default:
|
||||
return "add"
|
||||
}
|
||||
}
|
||||
|
||||
func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) {
|
||||
ctx := &types.RelationshipContext{
|
||||
TenantID: tenantID,
|
||||
@@ -1381,6 +1537,20 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type
|
||||
return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt)
|
||||
}
|
||||
|
||||
func (messagesMapper) Search(tenantID types.TenantID, topic string, forUser types.Uid, query, messageType, cursor string, limit int) ([]types.Message, string, error) {
|
||||
if topic == "" || forUser.IsZero() {
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tenantAdp.TenantMessageSearch(tenantID, topic, forUser, query, messageType, cursor, limit)
|
||||
}
|
||||
|
||||
// GetDeleted returns the ranges of deleted messages and the largest DelId reported in the list.
|
||||
func (messagesMapper) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
@@ -1649,6 +1819,8 @@ type FilePersistenceInterface interface {
|
||||
// LinkAttachments connects earlier uploaded attachments to a message or topic to prevent it
|
||||
// from being garbage collected.
|
||||
LinkAttachments(tenantID types.TenantID, topic string, msgId types.Uid, attachments []string) error
|
||||
// UserHasAccess checks if user can download a file through an active upload, user, topic, or message reference.
|
||||
UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error)
|
||||
}
|
||||
|
||||
// fileMapper is concrete type which implements FilePersistenceInterface.
|
||||
@@ -1738,6 +1910,17 @@ func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fileMapper) UserHasAccess(tenantID types.TenantID, fid string, uid types.Uid) (bool, error) {
|
||||
if fid == "" || uid.IsZero() {
|
||||
return false, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tenantAdp.TenantFileUserHasAccess(tenantID, fid, uid)
|
||||
}
|
||||
|
||||
// PersistentCacheInterface is an interface which defines methods used for accessing persistent key-value cache.
|
||||
type PersistentCacheInterface interface {
|
||||
// Get reads a persistent cache entry.
|
||||
@@ -2004,4 +2187,5 @@ func init() {
|
||||
Verifications = verificationMapper{}
|
||||
Security = userSecurityMapper{}
|
||||
Relations = relationsMapper{}
|
||||
Favorites = favoritesMapper{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
type FavoriteItem struct {
|
||||
ID int64
|
||||
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
|
||||
FavoriteID string `db:"favorite_id"`
|
||||
UserID Uid `db:"user_id"`
|
||||
ItemType string `db:"item_type"`
|
||||
SourceTopic string `db:"source_topic"`
|
||||
SourceSeq int `db:"source_seq"`
|
||||
Snapshot any
|
||||
AttachmentRefs any `db:"attachment_refs"`
|
||||
Availability string `db:"availability"`
|
||||
IdempotencyKey string `db:"idempotency_key"`
|
||||
CollectedAt time.Time `db:"collected_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
@@ -74,3 +74,16 @@ type FriendRequestListItem struct {
|
||||
Peer *User
|
||||
Direction string
|
||||
}
|
||||
|
||||
type UserSearchItem struct {
|
||||
User *User
|
||||
Relationship string
|
||||
RequestID string
|
||||
CanAdd bool
|
||||
Action string
|
||||
}
|
||||
|
||||
type BlockListItem struct {
|
||||
Block UserBlock
|
||||
User *User
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ type Tenant struct {
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
TenantDesc *string `db:"tenant_desc" json:"tenant_desc,omitempty"`
|
||||
DBName string `db:"db_name" json:"-"`
|
||||
DBDSN string `db:"db_dsn" json:"-"`
|
||||
State TenantState `db:"state" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"-"`
|
||||
CreatedBy int64 `db:"created_by" json:"-"`
|
||||
|
||||
+5
-1
@@ -290,7 +290,11 @@
|
||||
"mysql": {
|
||||
// MySQL connection settings via DSN.
|
||||
// See https://github.com/go-sql-driver/mysql#dsn-data-source-name for syntax.
|
||||
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
|
||||
"dsn": "mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci",
|
||||
// Public tenant registry database. Tenant business data remains in tinode or per-tenant DBs.
|
||||
"tenant_registry_db": "hotgo",
|
||||
// Route tenant-scoped auth/business queries to each tenant's DB according to hotgo.im_tenant.
|
||||
"tenant_db_routing": true,
|
||||
// MySQL connection pool settings.
|
||||
// Maximum number of open connections to the database. Default: 0 (unlimited).
|
||||
"max_open_conns": 64,
|
||||
|
||||
Reference in New Issue
Block a user