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

3720 lines
99 KiB
Go

//go:build postgres
// +build postgres
// Package postgres is a database adapter for PostgreSQL.
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"log"
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/auth"
"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 *pgxpool.Pool
poolConfig *pgxpool.Config
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
}
const (
adpVersion = 116
adapterName = "postgres"
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
)
type configType struct {
// DB connection settings:
// Using fields
User string `json:"user,omitempty"`
Passwd string `json:"passwd,omitempty"`
Host string `json:"host,omitempty"`
Port string `json:"port,omitempty"`
DBName string `json:"dbname,omitempty"`
// 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"`
// SSL mode determines how SSL connections are handled.
// Supported values:
// - "disable": No SSL connection (default)
// - "require": Require SSL connection but don't verify server certificate
// - "verify-ca": Require SSL and verify that the server certificate is issued by a trusted CA
// - "verify-full": Require SSL and verify that the server certificate matches the server hostname
// - "prefer": Try SSL first, fallback to non-SSL if SSL fails
// - "allow": Try non-SSL first, fallback to SSL if non-SSL fails
SSLMode string `json:"ssl_mode,omitempty"`
// DB request timeout (in seconds).
// If 0 (or negative), no timeout is applied.
SqlTimeout int `json:"sql_timeout,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
}
// Open initializes database session
func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.db != nil {
return errors.New("postgres adapter is already connected")
}
if len(jsonconfig) < 2 {
return errors.New("postgres adapter missing config")
}
var err error
var config configType
ctx := context.Background()
if err = json.Unmarshal(jsonconfig, &config); err != nil {
return errors.New("postgres adapter failed to parse config: " + err.Error())
}
if config.DSN != "" {
a.dsn = config.DSN
if uri, err := url.Parse(a.dsn); err == nil {
a.dbName = strings.TrimPrefix(uri.Path, "/")
} else {
return err
}
} else {
if a.dsn, err = setConnStr(config); err != nil {
return err
}
a.dbName = config.DBName
}
if a.maxResults <= 0 {
a.maxResults = defaultMaxResults
}
if a.maxMessageResults <= 0 {
a.maxMessageResults = defaultMaxMessageResults
}
if a.poolConfig, err = pgxpool.ParseConfig(a.dsn); err != nil {
return errors.New("postgres adapter failed to parse DSN: " + err.Error())
}
// NewWithConfig only creates a pool object. Ping to validate connectivity.
a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
if err != nil {
return err
}
err = a.db.Ping(ctx)
if isMissingDb(err) {
// Missing DB is OK if we are initializing the database.
// Since tinode DB does not exist, connect without specifying the DB name.
a.db.Close()
a.poolConfig.ConnConfig.Database = ""
a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
if err != nil {
return err
}
err = a.db.Ping(ctx)
}
if err != nil {
return err
}
if err == nil {
if config.MaxOpenConns > 0 {
a.poolConfig.MaxConns = int32(config.MaxOpenConns)
}
if config.MaxIdleConns > 0 {
a.poolConfig.MinConns = int32(config.MaxIdleConns)
}
if config.ConnMaxLifetime > 0 {
a.poolConfig.MaxConnLifetime = 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
}
}
return err
}
// Close closes the underlying database connection
func (a *adapter) Close() error {
if a.db != nil {
a.db.Close()
a.db = nil
a.version = -1
}
return nil
}
// 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 string
err := a.db.QueryRow(ctx, "SELECT value FROM im_kvmeta WHERE key='version'").Scan(&vers)
if err != nil {
if isMissingDb(err) || isMissingTable(err) || err == pgx.ErrNoRows {
err = errors.New("Database not initialized")
}
return -1, err
}
a.version, _ = strconv.Atoi(vers)
return a.version, nil
}
func (a *adapter) updateDbVersion(v int) error {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
a.version = -1
if _, err := a.db.Exec(ctx, `UPDATE im_kvmeta SET "value"=$1 WHERE "key"='version'`, strconv.Itoa(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.Stat()
}
// 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
}
// CreateDb initializes the storage.
func (a *adapter) CreateDb(reset bool) error {
var err error
var tx pgx.Tx
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
// Can't use an existing connection because it's configured with a database name which may not exist.
// Don't care if it does not close cleanly.
if a.db != nil {
a.db.Close()
}
// Create default database name
a.poolConfig.ConnConfig.Database = "postgres"
a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
if err != nil {
return err
}
if err = a.db.Ping(ctx); err != nil {
return err
}
if reset {
if _, err = a.db.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s;", a.dbName)); err != nil {
return err
}
}
if _, err = a.db.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s WITH ENCODING utf8;", a.dbName)); err != nil {
return err
}
a.poolConfig.ConnConfig.Database = a.dbName
a.db, err = pgxpool.NewWithConfig(ctx, a.poolConfig)
if err != nil {
return err
}
if err = a.db.Ping(ctx); err != nil {
return err
}
if tx, err = a.db.Begin(ctx); err != nil {
return err
}
defer func() {
if err != nil {
// FIXME: This is useless: MySQL auto-commits on every CREATE TABLE.
// Maybe DROP DATABASE instead.
tx.Rollback(ctx)
}
}()
// Indexed im_users.
if _, err := tx.Exec(ctx,
`CREATE TABLE im_users(
id BIGINT NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
updatedat TIMESTAMP(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
stateat TIMESTAMP(3),
access JSON,
lastseen TIMESTAMP,
useragent VARCHAR(255) DEFAULT '',
public JSON,
trusted JSON,
tags JSON,
PRIMARY KEY(id)
);
CREATE INDEX users_state_stateat ON im_users(state, stateat);
CREATE INDEX users_lastseen_updatedat ON im_users(lastseen, updatedat);`); err != nil {
return err
}
// Indexed user tags.
if _, err = tx.Exec(ctx,
`CREATE TABLE im_usertags(
id SERIAL NOT NULL,
userid BIGINT NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id)
);
CREATE INDEX usertags_tag ON im_usertags(tag);
CREATE UNIQUE INDEX usertags_userid_tag ON im_usertags(userid, tag);`); err != nil {
return err
}
// Indexed im_devices. Normalized into a separate table.
if _, err = tx.Exec(ctx,
`CREATE TABLE im_devices(
id SERIAL NOT NULL,
userid BIGINT NOT NULL,
hash CHAR(16) NOT NULL,
deviceid TEXT NOT NULL,
platform VARCHAR(32),
lastseen TIMESTAMP NOT NULL,
lang VARCHAR(8),
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id)
);
CREATE UNIQUE INDEX devices_hash ON im_devices(hash);`); err != nil {
return err
}
// Authentication records for the basic authentication scheme.
if _, err = tx.Exec(ctx,
`CREATE TABLE im_auth(
id SERIAL NOT NULL,
uname VARCHAR(32) NOT NULL,
userid BIGINT NOT NULL,
scheme VARCHAR(16) NOT NULL,
authlvl INT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires TIMESTAMP,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id)
);
CREATE UNIQUE INDEX auth_userid_scheme ON im_auth(userid, scheme);
CREATE UNIQUE INDEX auth_uname ON im_auth(uname);`); err != nil {
return err
}
// Topics
if _, err = tx.Exec(ctx,
`CREATE TABLE im_topics(
id SERIAL NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
updatedat TIMESTAMP(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
stateat TIMESTAMP(3),
touchedat TIMESTAMP(3),
name VARCHAR(25) NOT NULL,
usebt BOOLEAN DEFAULT FALSE,
owner BIGINT NOT NULL DEFAULT 0,
access JSON,
seqid INT NOT NULL DEFAULT 0,
delid INT DEFAULT 0,
subcnt INT DEFAULT 0,
public JSON,
trusted JSON,
tags JSON,
aux JSON,
PRIMARY KEY(id)
);
CREATE UNIQUE INDEX topics_name ON im_topics(name);
CREATE INDEX topics_owner ON im_topics(owner);
CREATE INDEX topics_state_stateat ON im_topics(state, stateat);
CREATE INDEX topics_name_state_seqid ON im_topics(name, state, seqid);`); err != nil {
return err
}
// Create system topic 'sys'.
if err = createSystemTopic(tx); err != nil {
return err
}
// Indexed topic tags.
if _, err = tx.Exec(ctx,
`CREATE TABLE im_topictags(
id SERIAL NOT NULL,
topic VARCHAR(25) NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES im_topics(name)
);
CREATE INDEX topictags_tag ON im_topictags(tag);
CREATE UNIQUE INDEX topictags_topic_tag ON im_topictags(topic, tag);`); err != nil {
return err
}
// Subscriptions
if _, err = tx.Exec(ctx,
`CREATE TABLE im_subscriptions(
id SERIAL NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
updatedat TIMESTAMP(3) NOT NULL,
deletedat TIMESTAMP(3),
userid BIGINT NOT NULL,
topic VARCHAR(25) NOT NULL,
delid INT DEFAULT 0,
recvseqid INT DEFAULT 0,
readseqid INT DEFAULT 0,
modewant VARCHAR(8),
modegiven VARCHAR(8),
private JSON,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id)
);
CREATE UNIQUE INDEX subscriptions_topic_userid ON im_subscriptions(topic, userid);
CREATE INDEX subscriptions_topic ON im_subscriptions(topic);
CREATE INDEX subscriptions_deletedat ON im_subscriptions(deletedat);
CREATE INDEX subscriptions_userid_topic_deletedat ON im_subscriptions(userid, topic, deletedat);`); err != nil {
return err
}
// Messages
if _, err = tx.Exec(ctx,
`CREATE TABLE im_messages(
id SERIAL NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
updatedat TIMESTAMP(3) NOT NULL,
deletedat TIMESTAMP(3),
delid INT DEFAULT 0,
seqid INT NOT NULL,
topic VARCHAR(25) NOT NULL,
"from" BIGINT NOT NULL,
head JSON,
content JSON,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES im_topics(name)
);
CREATE UNIQUE INDEX messages_topic_seqid ON im_messages(topic, seqid);`); err != nil {
return err
}
// Deletion log
if _, err = tx.Exec(ctx,
`CREATE TABLE im_dellog(
id SERIAL NOT NULL,
topic VARCHAR(25) NOT NULL,
deletedfor BIGINT NOT NULL DEFAULT 0,
delid INT NOT NULL,
low INT NOT NULL,
hi INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES im_topics(name)
);
CREATE INDEX dellog_topic_delid_deletedfor ON im_dellog(topic,delid,deletedfor);
CREATE INDEX dellog_topic_deletedfor_low_hi ON im_dellog(topic,deletedfor,low,hi);
CREATE INDEX dellog_deletedfor ON im_dellog(deletedfor);`); err != nil {
return err
}
// User im_credentials
if _, err = tx.Exec(ctx,
`CREATE TABLE im_credentials(
id SERIAL NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
updatedat TIMESTAMP(3) NOT NULL,
deletedat TIMESTAMP(3),
method VARCHAR(16) NOT NULL,
value VARCHAR(128) NOT NULL,
synthetic VARCHAR(192) NOT NULL,
userid BIGINT NOT NULL,
resp VARCHAR(255),
done BOOLEAN NOT NULL DEFAULT FALSE,
retries INT NOT NULL DEFAULT 0,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id)
);
CREATE UNIQUE INDEX credentials_uniqueness ON im_credentials(synthetic);`); err != nil {
return err
}
// Records of uploaded files.
// Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion.
if _, err = tx.Exec(ctx,
`CREATE TABLE im_fileuploads(
id BIGINT NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
updatedat TIMESTAMP(3) NOT NULL,
userid BIGINT,
status INT NOT NULL,
mimetype VARCHAR(255) NOT NULL,
size BIGINT NOT NULL,
etag VARCHAR(128),
location VARCHAR(2048) NOT NULL,
PRIMARY KEY(id)
);
CREATE INDEX fileuploads_status ON im_fileuploads(status);`); err != nil {
return err
}
// Links between uploaded files and the im_topics, im_users or im_messages they are attached to.
if _, err = tx.Exec(ctx,
`CREATE TABLE im_filemsglinks(
id SERIAL NOT NULL,
createdat TIMESTAMP(3) NOT NULL,
fileid BIGINT NOT NULL,
msgid INT,
topic VARCHAR(25),
userid BIGINT,
PRIMARY KEY(id),
FOREIGN KEY(fileid) REFERENCES im_fileuploads(id) ON DELETE CASCADE,
FOREIGN KEY(msgid) REFERENCES im_messages(id) ON DELETE CASCADE,
FOREIGN KEY(topic) REFERENCES im_topics(name) ON DELETE CASCADE,
FOREIGN KEY(userid) REFERENCES im_users(id) ON DELETE CASCADE
);`); err != nil {
return err
}
if _, err = tx.Exec(ctx,
`CREATE TABLE im_kvmeta(
"key" VARCHAR(64) NOT NULL,
createdat TIMESTAMP(3),
"value" TEXT,
PRIMARY KEY("key")
);
CREATE INDEX kvmeta_createdat_key ON im_kvmeta(createdat, "key");`); err != nil {
return err
}
if _, err = tx.Exec(ctx, `INSERT INTO im_kvmeta("key", "value") VALUES($1, $2)`, "version", strconv.Itoa(adpVersion)); err != nil {
return err
}
return tx.Commit(ctx)
}
// UpgradeDb upgrades the database, if necessary.
func (a *adapter) UpgradeDb() error {
bumpVersion := func(a *adapter, x int) error {
if err := a.updateDbVersion(x); err != nil {
return err
}
_, err := a.GetDbVersion()
return err
}
if _, err := a.GetDbVersion(); err != nil {
return err
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if a.version == 112 {
// Perform database upgrade from version 112 to version 113.
// Index for deleting unvalidated accounts.
if _, err := a.db.Exec(ctx, "CREATE INDEX users_lastseen_updatedat ON im_users(lastseen,updatedat)"); err != nil {
return err
}
// Allow lnger im_kvmeta keys.
if _, err := a.db.Exec(ctx, `ALTER TABLE im_kvmeta ALTER COLUMN "key" TYPE VARCHAR(64)`); err != nil {
return err
}
if _, err := a.db.Exec(ctx, `ALTER TABLE im_kvmeta ALTER COLUMN "key" SET NOT NULL`); err != nil {
return err
}
// Add timestamp to im_kvmeta.
if _, err := a.db.Exec(ctx, `ALTER TABLE im_kvmeta ADD COLUMN createdat TIMESTAMP(3)`); err != nil {
return err
}
// Add compound index on the new field and key (could be searched by key prefix).
if _, err := a.db.Exec(ctx, `CREATE INDEX kvmeta_createdat_key ON im_kvmeta(createdat, "key")`); err != nil {
return err
}
if err := bumpVersion(a, 113); err != nil {
return err
}
}
if a.version == 113 {
// Perform database upgrade from version 113 to version 114.
if _, err := a.db.Exec(ctx, "ALTER TABLE im_topics ADD COLUMN aux JSON"); err != nil {
return err
}
if _, err := a.db.Exec(ctx, "ALTER TABLE im_fileuploads ADD COLUMN etag VARCHAR(128)"); err != nil {
return err
}
if err := bumpVersion(a, 114); err != nil {
return err
}
}
if a.version == 114 {
// Perform database upgrade from version 114 to version 115.
// Find relevant im_subscriptions for given im_users efficiently, and use the join key too.
if _, err := a.db.Exec(ctx, "CREATE INDEX idx_subs_user_topic_del ON im_subscriptions(userid, topic, deletedat)"); err != nil {
return err
}
// Optimizes join; state filters; seqid supports the SUM operation.
if _, err := a.db.Exec(ctx, "CREATE INDEX idx_topics_name_state_seqid ON im_topics(name, state, seqid)"); err != nil {
return err
}
if err := bumpVersion(a, 115); err != nil {
return err
}
}
if a.version == 115 {
// Perform database upgrade from version 115 to version 116.
// Add subscriber count column to the im_topics table.
if _, err := a.db.Exec(ctx, "ALTER TABLE im_topics ADD subcnt INT DEFAULT 0"); err != nil {
return err
}
if err := bumpVersion(a, 116); 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))
}
return nil
}
func createSystemTopic(tx pgx.Tx) error {
now := t.TimeNow()
query := `INSERT INTO im_topics(createdat,updatedat,state,touchedat,name,access,public)
VALUES($1,$2,$3,$4,'sys','{"Auth": "N","Anon": "N"}','{"fn": "System"}')`
_, err := tx.Exec(context.Background(), query, now, now, t.StateOK, now)
return err
}
func addTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, tags []string, ignoreDups bool) error {
if len(tags) == 0 {
return nil
}
//addTags(ctx, tx, "im_usertags", "userid", decoded_uid, add, reset == nil)
sql := "INSERT INTO " + table + " (" + keyName + ",tag) VALUES($1,$2)"
if ignoreDups {
sql += " ON CONFLICT DO NOTHING"
}
for _, tag := range tags {
if _, err := tx.Exec(ctx, sql, keyVal, tag); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
return nil
}
func removeTags(ctx context.Context, tx pgx.Tx, table, keyName string, keyVal any, tags []string) error {
if len(tags) == 0 {
return nil
}
sql, args := expandQuery("DELETE FROM "+table+" WHERE "+keyName+"=? AND tag IN (?)", keyVal, tags)
_, err := tx.Exec(ctx, sql, 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 {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
decoded_uid := store.DecodeUid(user.Uid())
if _, err = tx.Exec(ctx,
"INSERT INTO im_users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES($1,$2,$3,$4,$5,$6,$7,$8);",
decoded_uid,
user.CreatedAt,
user.UpdatedAt,
user.State,
user.Access,
common.ToJSON(user.Public),
common.ToJSON(user.Trusted),
user.Tags); err != nil {
return err
}
// Save user's tags to a separate table to make user findable.
if err = addTags(ctx, tx, "im_usertags", "userid", decoded_uid, user.Tags, false); err != nil {
return err
}
return tx.Commit(ctx)
}
// 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()
}
if _, err := a.db.Exec(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES($1,$2,$3,$4,$5,$6)",
unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
return nil
}
// AuthDelScheme deletes an existing authentication scheme for the user.
func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.Exec(ctx, "DELETE FROM im_auth WHERE userid=$1 AND scheme=$2", store.DecodeUid(user), scheme)
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.Exec(ctx, "DELETE FROM im_auth WHERE userid=$1", store.DecodeUid(user))
if err != nil {
return 0, err
}
count := res.RowsAffected()
return int(count), nil
}
// Update user's authentication unique, secret, im_auth level.
func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error {
parapg := []string{"authLvl=?"}
args := []any{authLvl}
if unique != "" {
parapg = append(parapg, "uname=?")
args = append(args, unique)
}
if len(secret) > 0 {
parapg = append(parapg, "secret=?")
args = append(args, secret)
}
if !expires.IsZero() {
parapg = append(parapg, "expires=?")
args = append(args, expires)
}
args = append(args, store.DecodeUid(uid), scheme)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
sql, args := expandQuery("UPDATE im_auth SET "+strings.Join(parapg, ",")+" WHERE userid=? AND scheme=?", args...)
resp, err := a.db.Exec(ctx, sql, args...)
if isDupe(err) {
return t.ErrDuplicate
}
if count := resp.RowsAffected(); count <= 0 {
return t.ErrNotFound
}
return err
}
// 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 {
Uname string
Authlvl auth.Level
Secret []byte
Expires *time.Time
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err := a.db.QueryRow(ctx, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=$1 AND scheme=$2",
store.DecodeUid(uid), scheme).Scan(
&record.Uname, &record.Secret, &record.Expires, &record.Authlvl); err != nil {
if err == pgx.ErrNoRows {
// Nothing found - use standard error.
err = t.ErrNotFound
}
return "", 0, nil, expires, err
}
if record.Expires != nil {
expires = *record.Expires
}
return record.Uname, 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()
}
if err := a.db.QueryRow(ctx, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=$1", unique).Scan(
&record.Userid, &record.Secret, &record.Expires, &record.Authlvl); err != nil {
if err == pgx.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 user t.User
var id int64
row, err := a.db.Query(ctx, "SELECT * FROM im_users WHERE id=$1 AND state!=$2", store.DecodeUid(uid), t.StateDeleted)
if err != nil {
return nil, err
}
defer row.Close()
if !row.Next() {
// Nothing found: user does not exist or marked as soft-deleted
return nil, nil
}
err = row.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags)
if err == nil {
user.SetUid(uid)
return &user, nil
}
return nil, err
}
func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
uids := make([]any, len(ids))
for i, id := range ids {
uids[i] = store.DecodeUid(id)
}
im_users := []t.User{}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.Query(ctx, "SELECT * FROM im_users WHERE id = ANY ($1) AND state!=$2", uids, t.StateDeleted)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var user t.User
var id int64
if err = rows.Scan(&id, &user.CreatedAt, &user.UpdatedAt, &user.State, &user.StateAt, &user.Access, &user.LastSeen, &user.UserAgent, &user.Public, &user.Trusted, &user.Tags); err != nil {
im_users = nil
break
}
user.SetUid(store.EncodeUid(id))
im_users = append(im_users, user)
}
if err == nil {
err = rows.Err()
}
return im_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=$1"
args := []any{store.DecodeUid(uid)}
// In case of hard delete, delete all im_topics, even those which were
// soft-deleted previsously.
if !hard {
query += " AND state!=$2"
args = append(args, t.StateDeleted)
}
// Get a list of topic names owned by the user (as 'grp' and 'chn').
ownTopics, err := a.topicNamesForUser(query, false, args...)
if err != nil {
return err
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
now := t.TimeNow()
decoded_uid := store.DecodeUid(uid)
if hard {
// Delete user's im_devices
// t.ErrNotFound = user has no im_devices.
if err = deviceDelete(ctx, tx, uid, ""); err != nil && err != t.ErrNotFound {
return err
}
// Delete user's im_subscriptions in all im_topics.
if err = subsDelForUser(ctx, tx, decoded_uid, true); err != nil {
return err
}
// Delete records of im_messages soft-deleted for the user.
if _, err = tx.Exec(ctx, "DELETE FROM im_dellog WHERE deletedfor=$1", decoded_uid); err != nil {
return err
}
// Can't delete user's im_messages in all im_topics because we cannot notify im_topics of such deletion.
// Just leave the im_messages there marked as sent by "not found" user.
// Delete im_topics where the user is the owner.
if len(ownTopics) > 0 {
// First delete all im_messages in those im_topics.
if _, err = tx.Exec(ctx, "DELETE FROM im_dellog USING im_topics WHERE im_topics.name=im_dellog.topic AND im_topics.owner=$1",
decoded_uid); err != nil {
return err
}
// Deletion of im_messages will cascade to im_filemsglinks and so to im_fileuploads.
if _, err = tx.Exec(ctx, "DELETE FROM im_messages USING im_topics WHERE im_topics.name=im_messages.topic AND im_topics.owner=$1",
decoded_uid); err != nil {
return err
}
// Delete im_subscriptions for all im_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(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil {
return err
}
// Delete topic tags.
if _, err = tx.Exec(ctx, "DELETE FROM im_topictags USING im_topics WHERE im_topics.name=im_topictags.topic AND im_topics.owner=$1",
decoded_uid); err != nil {
return err
}
// And finally delete the im_topics.
if _, err = tx.Exec(ctx, "DELETE FROM im_topics WHERE owner=$1", decoded_uid); err != nil {
return err
}
}
// Delete user's authentication records.
if _, err = tx.Exec(ctx, "DELETE FROM im_auth WHERE userid=$1", decoded_uid); err != nil {
return err
}
// Delete all im_credentials.
if err = credDel(ctx, tx, uid, "", ""); err != nil && err != t.ErrNotFound {
return err
}
if _, err = tx.Exec(ctx, "DELETE FROM im_usertags WHERE userid=$1", decoded_uid); err != nil {
return err
}
if _, err = tx.Exec(ctx, "DELETE FROM im_users WHERE id=$1", decoded_uid); err != nil {
return err
}
} else {
// Disable all user's im_subscriptions. That includes p2p im_subscriptions. No need to delete them.
if err = subsDelForUser(ctx, tx, decoded_uid, false); err != nil {
return err
}
if len(ownTopics) > 0 {
// Disable all im_subscriptions to im_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(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...); err != nil {
return err
}
// Disable group im_topics where the user is the owner.
if _, err = tx.Exec(ctx, "UPDATE im_topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE owner=$3",
now, t.StateDeleted, decoded_uid); err != nil {
return err
}
}
// Disable p2p im_topics with the user (p2p topic's owner is 0).
if _, err = tx.Exec(ctx, "UPDATE im_topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 "+
"FROM im_subscriptions WHERE im_topics.name=im_subscriptions.topic "+
"AND im_topics.owner=0 AND im_subscriptions.userid=$3",
now, t.StateDeleted, decoded_uid); err != nil {
return err
}
// Disable the other user's subscription to a disabled p2p topic.
if _, err = tx.Exec(ctx, "UPDATE im_subscriptions AS s_one SET updatedat=$1,deletedat=$1 "+
"FROM im_subscriptions AS s_two WHERE s_one.topic=s_two.topic "+
"AND s_two.userid=$2 AND s_two.topic LIKE 'p2p%'",
now, decoded_uid); err != nil {
return err
}
// Disable user.
if _, err = tx.Exec(ctx, "UPDATE im_users SET updatedat=$1,state=$2,stateat=$1 WHERE id=$3",
now, t.StateDeleted, decoded_uid); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// topicStateForUser is called by UserUpdate when the update contains state change.
// Soft-deleted im_topics remain soft-deleted.
func (a *adapter) topicStateForUser(ctx context.Context, tx pgx.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 im_topics where the user is the owner.
if _, err = tx.Exec(ctx, "UPDATE im_topics SET state=$1, stateat=$2 WHERE owner=$3 AND state!=$4",
state, now, decoded_uid, t.StateDeleted); err != nil {
return err
}
// Change state of p2p im_topics with the user (p2p topic's owner is 0)
if _, err = tx.Exec(ctx, "UPDATE im_topics SET state=$1, stateat=$2 "+
"FROM im_subscriptions WHERE im_topics.name=im_subscriptions.topic AND "+
"im_topics.owner=0 AND im_subscriptions.userid=$3 AND im_topics.state!=$4",
state, now, decoded_uid, t.StateDeleted); err != nil {
return err
}
// Subscriptions don't need to be updated:
// im_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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
cols, args := common.UpdateByMap(update)
decoded_uid := store.DecodeUid(uid)
args = append(args, decoded_uid)
sql, args := expandQuery("UPDATE im_users SET "+strings.Join(cols, ",")+" WHERE id=?", args...)
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return err
}
if state, ok := update["State"]; ok {
now, _ := update["StateAt"].(time.Time)
err = a.topicStateForUser(ctx, tx, decoded_uid, now, state)
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(ctx, "DELETE FROM im_usertags WHERE userid=$1", decoded_uid)
if err != nil {
return err
}
// Now insert new tags
err = addTags(ctx, tx, "im_usertags", "userid", decoded_uid, tags, false)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
func tempFetchTags(ctx context.Context, tx pgx.Tx, decoded_uid int64) ([]string, error) {
var allTags []string
rows, err := tx.Query(ctx, "SELECT tag FROM im_usertags WHERE userid=$1", decoded_uid)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var tag string
rows.Scan(&tag)
allTags = append(allTags, tag)
}
return allTags, nil
}
// UserUpdateTags adds or resets user's tags
func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
decoded_uid := store.DecodeUid(uid)
if reset != nil {
// Delete all tags first if resetting.
_, err = tx.Exec(ctx, "DELETE FROM im_usertags WHERE userid=$1", decoded_uid)
if err != nil {
return nil, err
}
add = reset
remove = nil
}
// Now insert new tags. Ignore duplicates if resetting.
err = addTags(ctx, tx, "im_usertags", "userid", decoded_uid, add, reset == nil)
if err != nil {
return nil, err
}
// Delete tags.
err = removeTags(ctx, tx, "im_usertags", "userid", decoded_uid, remove)
if err != nil {
return nil, err
}
var allTags []string
rows, err := tx.Query(ctx, "SELECT tag FROM im_usertags WHERE userid=$1", decoded_uid)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var tag string
rows.Scan(&tag)
allTags = append(allTags, tag)
}
_, err = tx.Exec(ctx, "UPDATE im_users SET tags=$1 WHERE id=$2", t.StringSlice(allTags), decoded_uid)
if err != nil {
return nil, err
}
return allTags, tx.Commit(ctx)
}
// 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
err := a.db.QueryRow(ctx, "SELECT userid FROM im_credentials WHERE synthetic=$1", method+":"+value).Scan(&decoded_uid)
if err == nil {
return store.EncodeUid(decoded_uid), nil
}
if err == pgx.ErrNoRows {
// Clear the error if user does not exist
return t.ZeroUid, nil
}
return t.ZeroUid, err
}
// UserUnreadCount returns the total number of unread im_messages in all im_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 im_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.
query, uids := expandQuery("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 "+
"POSITION('R' IN s.modewant)>0 AND POSITION('R' IN s.modegiven)>0 GROUP BY s.userid", uids, t.StateDeleted)
rows, err := a.db.Query(ctx, query, uids...)
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 im_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.Query(ctx,
"SELECT u.id, COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) AS total "+
"FROM im_users u LEFT JOIN im_credentials c ON u.id = c.userid "+
"WHERE u.lastseen IS NULL AND u.updatedat < $1 GROUP BY u.id, u.updatedat "+
"HAVING COALESCE(SUM(CASE WHEN c.done THEN 1 ELSE 0 END), 0) = 0 ORDER BY u.updatedat ASC LIMIT $2",
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(ctx context.Context, tx pgx.Tx, topic *t.Topic) error {
_, err := tx.Exec(ctx, "INSERT INTO im_topics(createdat,updatedat,touchedat,state,name,usebt,owner,access,public,trusted,tags,aux) "+
"VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)",
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(ctx, 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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
err = a.topicCreate(ctx, tx, topic)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// If undelete = true - update subscription on duplicate key, otherwise ignore the duplicate.
func createSubscription(ctx context.Context, tx pgx.Tx, sub *t.Subscription, undelete bool) error {
isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner()
jpriv := common.ToJSON(sub.Private)
decoded_uid := store.DecodeUid(t.ParseUid(sub.User))
_, err2 := tx.Exec(ctx, "SAVEPOINT createSub")
if err2 != nil {
log.Println("Error: Failed to create savepoint: ", err2.Error())
}
_, err := tx.Exec(ctx,
"INSERT INTO im_subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+
"VALUES($1,$2,NULL,$3,$4,$5,$6,$7)",
sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv)
if err != nil && isDupe(err) {
_, err2 = tx.Exec(ctx, "ROLLBACK TO SAVEPOINT createSub")
if err2 != nil {
log.Println("Error: Failed to rollback savepoint: ", err2.Error())
}
if undelete {
_, err = tx.Exec(ctx, "UPDATE im_subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+
"delid=0,recvseqid=0,readseqid=0 WHERE topic=$5 AND userid=$6",
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid)
} else {
_, err = tx.Exec(ctx, "UPDATE im_subscriptions SET createdat=$1,updatedat=$2,deletedat=NULL,modeWant=$3,modeGiven=$4,"+
"delid=0,recvseqid=0,readseqid=0,private=$5 WHERE topic=$6 AND userid=$7",
sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv,
sub.Topic, decoded_uid)
}
} else {
_, err2 = tx.Exec(ctx, "RELEASE SAVEPOINT createSub")
if err2 != nil {
log.Println("Error: Failed to release savepoint: ", err2.Error())
}
}
if err == nil && isOwner {
_, err = tx.Exec(ctx, "UPDATE im_topics SET owner=$1 WHERE name=$2", decoded_uid, sub.Topic)
}
return err
}
// TopicCreateP2P given two im_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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
err = createSubscription(ctx, tx, initiator, false)
if err != nil {
return err
}
err = createSubscription(ctx, 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()
err = a.topicCreate(ctx, tx, topic)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// 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.getContextForTx()
if cancel != nil {
defer cancel()
}
// Fetch topic by name
var tt = new(t.Topic)
var owner int64
err := a.db.QueryRow(ctx,
"SELECT createdat,updatedat,state,stateat,touchedat,name AS id,usebt,access,owner,seqid,delid,subcnt,public,trusted,tags,aux "+
"FROM im_topics WHERE name=$1",
topic).Scan(&tt.CreatedAt, &tt.UpdatedAt, &tt.State, &tt.StateAt, &tt.TouchedAt, &tt.Id,
&tt.UseBt, &tt.Access, &owner, &tt.SeqId, &tt.DelId, &tt.SubCnt, &tt.Public, &tt.Trusted, &tt.Tags, &tt.Aux)
if err != nil {
if err == pgx.ErrNoRows {
// Nothing found - clear the error
err = nil
}
return nil, err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
// Topic found, get subsription count. Try both topic and channel names.
var subCnt int
if err = a.db.QueryRow(ctx,
"SELECT COUNT(*) FROM im_subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL", topic, t.GrpToChn(topic)).
Scan(&subCnt); err != nil {
return nil, err
}
if subCnt != tt.SubCnt {
// Update the topic with the correct subscription count.
tt.SubCnt = subCnt
if _, err = a.db.Exec(ctx, "UPDATE im_topics SET subcnt=$1 WHERE name=$2", subCnt, topic); err != nil {
return nil, err
}
}
}
tt.Owner = store.EncodeUid(owner).String()
return tt, err
}
// TopicsForUser loads user's contact list: p2p and grp im_topics, except for 'me' & 'fnd' im_subscriptions.
// Reads and denormalizes Public value.
func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
// Fetch ALL user's im_subscriptions, even those which has not been modified recently.
// We are going to use these im_subscriptions to fetch im_topics and im_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 im_subscriptions and do a manual join with im_users/im_topics.
if opts.IfModifiedSince == nil {
if opts.Limit > 0 && opts.Limit < a.maxResults {
limit = opts.Limit
} else {
limit = a.maxResults
}
} else {
ims = *opts.IfModifiedSince
}
} else {
limit = a.maxResults
}
if limit > 0 {
q += " LIMIT ?"
args = append(args, limit)
}
q, args = expandQuery(q, args...)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
// Must close rows manually as we will be reusing it.
// Fetch im_subscriptions. Two queries are needed: im_users table (p2p) and im_topics table (grp).
// Prepare a list of separate im_subscriptions to im_users vs im_topics
join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
topq := make([]any, 0, 16)
usrq := make([]any, 0, 16)
for rows.Next() {
var sub t.Subscription
var modeWant, modeGiven []byte
if err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.Topic, &sub.DelId,
&sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private); err != nil {
break
}
sub.ModeWant.Scan(modeWant)
sub.ModeGiven.Scan(modeGiven)
tname := sub.Topic
sub.User = uid.String()
tcat := t.GetTopicCat(tname)
if tcat == t.TopicCatMe || tcat == t.TopicCatFnd {
// One of 'me', 'fnd' im_subscriptions, skip.
// Don't skip 'sys' subscription.
continue
} else if tcat == t.TopicCatP2P {
// P2P subscription, find the other user to get user.Public and user.Trusted.
uid1, uid2, _ := t.ParseP2P(tname)
if uid1 == uid {
usrq = append(usrq, store.DecodeUid(uid2))
sub.SetWith(uid2.UserId())
} else {
usrq = append(usrq, store.DecodeUid(uid1))
sub.SetWith(uid1.UserId())
}
} else if tcat == t.TopicCatGrp {
// Maybe convert channel name to topic name.
tname = t.ChnToGrp(tname)
}
// No special handling needed for 'slf', 'sys' im_subscriptions.
topq = append(topq, tname)
sub.Private = common.FromJSON(sub.Private)
join[tname] = sub
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return nil, err
}
var subs []t.Subscription
if len(join) == 0 {
return subs, nil
}
// Fetch grp im_topics and join to im_subscriptions.
if len(topq) > 0 {
q = "SELECT updatedat,state,touchedat,name AS id,usebt,access,seqid,delid,subcnt,public,trusted " +
"FROM im_topics WHERE name IN (?)"
newargs := []any{topq}
if !keepDeleted {
// Optionally skip deleted im_topics.
q += " AND state!=?"
newargs = append(newargs, t.StateDeleted)
}
if !ims.IsZero() {
// Use cache timestamp if provided: get newer entries only.
q += " AND touchedat>?"
newargs = append(newargs, ims)
if limit > 0 && limit < len(topq) {
// No point in fetching more than the requested limit.
q += " ORDER BY touchedat LIMIT ?"
newargs = append(newargs, limit)
}
}
q, newargs = expandQuery(q, newargs...)
ctx2, cancel2 := a.getContext()
if cancel2 != nil {
defer cancel2()
}
rows, err = a.db.Query(ctx2, q, newargs...)
if err != nil {
return nil, err
}
var top t.Topic
for rows.Next() {
if err = rows.Scan(&top.UpdatedAt, &top.State, &top.TouchedAt, &top.Id, &top.UseBt,
&top.Access, &top.SeqId, &top.DelId, &top.SubCnt, &top.Public, &top.Trusted); err != nil {
break
}
sub := join[top.Id]
// Check if sub.UpdatedAt needs to be adjusted to earlier or later time.
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
sub.SetState(top.State)
sub.SetTouchedAt(top.TouchedAt)
sub.SetSeqId(top.SeqId)
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
sub.SetSubCnt(top.SubCnt)
sub.SetPublic(top.Public)
sub.SetTrusted(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 im_users and join to p2p im_subscriptions.
if len(usrq) > 0 {
q = "SELECT id,updatedat,state,access,lastseen,useragent,public,trusted " +
"FROM im_users WHERE id IN (?)"
newargs := []any{usrq}
if !keepDeleted {
// Optionally skip deleted im_users.
q += " AND state!=?"
newargs = append(newargs, t.StateDeleted)
}
// Ignoring ipg: we need all im_users to get LastSeen and UserAgent.
q, newargs = expandQuery(q, newargs...)
ctx3, cancel3 := a.getContext()
if cancel3 != nil {
defer cancel3()
}
rows, err = a.db.Query(ctx3, q, newargs...)
if err != nil {
return nil, err
}
for rows.Next() {
var usr2 t.User
var id int64
if err = rows.Scan(&id, &usr2.UpdatedAt, &usr2.State, &usr2.Access, &usr2.LastSeen, &usr2.UserAgent,
&usr2.Public, &usr2.Trusted); err != nil {
break
}
usr2.Id = store.EncodeUid(id).String()
joinOn := uid.P2PName(t.ParseUid(usr2.Id))
if sub, ok := join[joinOn]; ok {
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, usr2.UpdatedAt)
sub.SetState(usr2.State)
sub.SetPublic(usr2.Public)
sub.SetTrusted(usr2.Trusted)
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
join[joinOn] = sub
}
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return nil, err
}
}
subs = make([]t.Subscription, 0, len(join))
for _, sub := range join {
subs = append(subs, sub)
}
return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
}
// UsersForTopic loads im_users subscribed to the given topic.
// 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 im_users. The number of im_users is not large
q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private
FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id
WHERE s.topic=?`
args := []any{topic}
if !keepDeleted {
// Filter out rows with im_users deleted
q += " AND u.state!=?"
args = append(args, t.StateDeleted)
// For p2p im_topics we must load all im_subscriptions including deleted.
// Otherwise it will be impossible to swipe Public values.
if tcat != t.TopicCatP2P {
// Filter out deleted im_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 im_topics we have to fetch both im_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)
q, args = expandQuery(q, args...)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Fetch im_subscriptions
var sub t.Subscription
var subs []t.Subscription
var userId int64
var modeWant, modeGiven []byte
var lastSeen *time.Time = nil
var userAgent string
var public, trusted any
for rows.Next() {
if err = rows.Scan(
&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&userId, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
&sub.ReadSeqId, &modeWant, &modeGiven,
&public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil {
break
}
sub.User = store.EncodeUid(userId).String()
sub.SetPublic(public)
sub.SetTrusted(trusted)
sub.SetLastSeenAndUA(lastSeen, userAgent)
sub.ModeWant.Scan(modeWant)
sub.ModeGiven.Scan(modeGiven)
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 im_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 im_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.
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.Query(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=$1 AND state!=$2",
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=$1 AND topic LIKE 'chn%' "+
"AND POSITION('P' IN modewant)>0 AND POSITION('P' IN modegiven)>0 AND deletedat IS NULL",
false, store.DecodeUid(uid))
}
// TopicShare creates topic im_subscriptions 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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
for _, sub := range shares {
err = createSubscription(ctx, tx, sub, true)
if err != nil {
return err
}
}
if topic != "" {
if _, err = tx.Exec(ctx, "UPDATE im_topics SET subcnt=subcnt+$1 WHERE name=$2", len(shares), topic); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// TopicDelete deletes topic, im_subscriptions, im_messages.
func (a *adapter) TopicDelete(topic string, isChan, hard bool) error {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
// If the topic is a channel, must try to delete im_subscriptions under both grpXXX and chnXXX names.
args := []any{topic}
if isChan {
args = append(args, t.GrpToChn(topic))
}
if hard {
// Delete im_subscriptions. If this is a channel, delete both group im_subscriptions and channel im_subscriptions.
q, args := expandQuery("DELETE FROM im_subscriptions WHERE topic IN (?)", args)
if _, err = tx.Exec(ctx, q, args...); err != nil {
return err
}
if err = messageDeleteList(ctx, tx, topic, nil); err != nil {
return err
}
if _, err = tx.Exec(ctx, "DELETE FROM im_topictags WHERE topic=$1", topic); err != nil {
return err
}
if _, err = tx.Exec(ctx, "DELETE FROM im_topics WHERE name=$1", topic); err != nil {
return err
}
} else {
now := t.TimeNow()
q, args := expandQuery("UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic IN (?)", now, now, args)
if _, err = tx.Exec(ctx, q, args...); err != nil {
return err
}
if _, err = tx.Exec(ctx, "UPDATE im_topics SET updatedat=$1,touchedat=$1,state=$2,stateat=$1 WHERE name=$3",
now, t.StateDeleted, topic); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.Exec(ctx, "UPDATE im_topics SET seqid=$1,touchedat=$2 WHERE name=$3", 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.Exec(ctx,
"UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions WHERE topic IN ($1,$2) AND deletedat IS NULL) WHERE name=$1",
topic, t.GrpToChn(topic))
return err
}
func (a *adapter) TopicUpdate(topic string, update map[string]any) error {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil {
update["TouchedAt"] = u
}
cols, args := common.UpdateByMap(update)
q, args := expandQuery("UPDATE im_topics SET "+strings.Join(cols, ",")+" WHERE name=?", args, topic)
_, err = tx.Exec(ctx, q, 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(ctx, "DELETE FROM im_topictags WHERE topic=$1", topic)
if err != nil {
return err
}
// Now insert new tags
err = addTags(ctx, tx, "im_topictags", "topic", topic, tags, false)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.Exec(ctx, "UPDATE im_topics SET owner=$1 WHERE name=$2", 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=$1 AND userid=$2`
if !keepDeleted {
query += " AND deletedat IS NULL"
}
var sub t.Subscription
var userId int64
var modeWant, modeGiven []byte
err := a.db.QueryRow(ctx, query, topic, store.DecodeUid(user)).Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId,
&sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private)
if err != nil {
if err == pgx.ErrNoRows {
// Nothing found - clear the error
err = nil
}
return nil, err
}
sub.User = store.EncodeUid(userId).String()
sub.ModeWant.Scan(modeWant)
sub.ModeGiven.Scan(modeGiven)
return &sub, nil
}
// SubsForUser loads all user's im_subscriptions. Does NOT load Public or Private values and does
// not load deleted im_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=$1 AND deletedat IS NULL`
args := []any{store.DecodeUid(forUser)}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
var sub t.Subscription
var userId int64
var modeWant, modeGiven []byte
for rows.Next() {
if err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId, &sub.Topic, &sub.DelId,
&sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven); err != nil {
break
}
sub.User = store.EncodeUid(userId).String()
sub.ModeWant.Scan(modeWant)
sub.ModeGiven.Scan(modeGiven)
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.
// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,
// the latter does not.
func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
q := `SELECT createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid,
readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=?`
args := []any{topic}
if !keepDeleted {
// Filter out deleted rows.
q += " AND deletedat IS NULL"
}
limit := a.maxResults
if opts != nil {
// Ignore IfModifiedSince - we must return all entries
// Those unmodified will be stripped of Public & Private.
if !opts.User.IsZero() {
q += " AND userid=?"
args = append(args, store.DecodeUid(opts.User))
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
q += " LIMIT ?"
args = append(args, limit)
q, args = expandQuery(q, args...)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var subs []t.Subscription
var sub t.Subscription
var userId int64
var modeWant, modeGiven []byte
for rows.Next() {
if err = rows.Scan(&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &userId, &sub.Topic, &sub.DelId,
&sub.RecvSeqId, &sub.ReadSeqId, &modeWant, &modeGiven, &sub.Private); err != nil {
break
}
sub.User = store.EncodeUid(userId).String()
sub.ModeWant.Scan(modeWant)
sub.ModeGiven.Scan(modeGiven)
subs = append(subs, sub)
}
if err == nil {
err = rows.Err()
}
return subs, err
}
// SubsUpdate updates one or multiple im_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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
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))
}
q, args = expandQuery(q, args...)
if _, err = tx.Exec(ctx, q, args...); err != nil {
return err
}
return tx.Commit(ctx)
}
// SubsDelete marks at most one subscription as deleted.
func (a *adapter) SubsDelete(topic string, user t.Uid) error {
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.Begin(ctx)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
decoded_id := store.DecodeUid(user)
now := t.TimeNow()
res, err := tx.Exec(ctx,
"UPDATE im_subscriptions SET updatedat=$1,deletedat=$2 WHERE topic=$3 AND userid=$4 AND deletedat IS NULL",
now, now, topic, decoded_id)
if err != nil {
return err
}
affected := res.RowsAffected()
if affected == 0 {
// ensure tx.Rollback() above is ran
err = t.ErrNotFound
return err
}
// Channel readers cannot delete im_messages.
if !t.IsChannel(topic) {
// Remove records of im_messages soft-deleted by this user.
_, err = tx.Exec(ctx, "DELETE FROM im_dellog WHERE topic=$1 AND deletedfor=$2", 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(ctx, "UPDATE im_topics SET subcnt=subcnt-1 WHERE name=$1", topic)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
// subsDelForUser marks user's im_subscriptions as deleted.
func subsDelForUser(ctx context.Context, tx pgx.Tx, decoded_uid int64, hard bool) error {
// Decrement subscription count for all im_topics the user is subscribed to.
rows, err := tx.Query(ctx, "SELECT topic FROM im_subscriptions WHERE userid=$1 AND deletedat IS NULL", decoded_uid)
if err != nil {
return err
}
var im_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)
}
im_topics = append(im_topics, name)
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return err
}
if len(im_topics) > 0 {
sql, args, _ := sqlx.In("UPDATE im_topics SET subcnt=subcnt-1 WHERE name IN (?)", im_topics)
_, err = tx.Exec(ctx, sqlx.Rebind(sqlx.DOLLAR, sql), args...)
if err != nil {
return err
}
}
if hard {
// Hard delete: remove all im_subscriptions for the user.
_, err = tx.Exec(ctx, "DELETE FROM im_subscriptions WHERE userid=$1", decoded_uid)
} else {
now := t.TimeNow()
_, err = tx.Exec(ctx, "UPDATE im_subscriptions SET updatedat=$1,deletedat=$2 WHERE userid=$3 AND deletedat IS NULL;",
now, now, decoded_uid)
}
return err
}
// SubsDelForUser marks user's im_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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
if err = subsDelForUser(ctx, tx, store.DecodeUid(user), hard); err != nil {
return err
}
return tx.Commit(ctx)
}
// Find returns a list of im_users and group im_topics which match the given tags, such as "email:jdoe@example.com" or "tel:+18003287448".
func (a *adapter) Find(caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
index := make(map[string]struct{})
var args []any
constraint := ""
allReq := t.FlattenDoubleSlice(req)
for _, tag := range append(allReq, opt...) {
args = append(args, tag)
index[tag] = struct{}{}
}
if len(args) == 0 {
// Nothing to search for.
return nil, nil
}
constraint += "tg.tag IN (?) "
constraint, args, err := sqlx.In(constraint, args)
if err != nil {
return nil, err
}
if activeOnly {
args = append(args, t.StateOK)
constraint += "AND state=? "
}
constraint = sqlx.Rebind(sqlx.DOLLAR, constraint)
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 POSITION('" + promoPrefix + "' IN tg.tag)=1 THEN 20 ELSE 1 END)"
} else {
matcher = "COUNT(*)"
}
query := "SELECT CAST(u.id AS VARCHAR) AS topic,u.createdat,u.updatedat,FALSE,u.access::jsonb,0 AS subcnt,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb," +
matcher + " AS matches " +
"FROM im_users AS u JOIN im_usertags AS tg ON tg.userid=u.id " +
"WHERE " + constraint +
"GROUP BY u.id,u.createdat,u.updatedat,u.access::jsonb,u.public::jsonb,u.trusted::jsonb,u.tags::jsonb "
having := ""
if len(allReq) > 0 {
var a []any
having, a = common.DisjunctionSql(req, "tg.tag")
having = rebindWithStart(having, len(args)+1)
query += having
args = append(args, a...)
}
query += "UNION ALL "
query += "SELECT t.name AS topic,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb," +
matcher + " AS matches " +
"FROM im_topics AS t JOIN im_topictags AS tg ON t.name=tg.topic " +
"WHERE " + constraint +
"GROUP BY t.name,t.createdat,t.updatedat,t.usebt,t.access::jsonb,t.subcnt,t.public::jsonb,t.trusted::jsonb,t.tags::jsonb "
if having != "" {
query += having
}
args = append(args, a.maxResults)
query += "ORDER BY matches DESC, subcnt DESC LIMIT $" + strconv.Itoa(len(args))
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
// Get im_users matched by tags, sort by number of matches from high to low.
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Fetch im_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.
sub.Topic = t.GrpToChn(sub.Topic)
}
sub.SetSubCnt(subcnt)
sub.SetPublic(public)
sub.SetTrusted(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 topic or user which matches the given tag.
func (a *adapter) FindOne(tag string) (string, error) {
var args []any
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=?"
args = append(args, tag)
query += " UNION ALL "
query += "SELECT CAST(u.id AS VARCHAR) AS topic FROM im_users AS u LEFT JOIN im_usertags AS ut ON ut.userid=u.id " +
"WHERE ut.tag=?"
args = append(args, tag)
// LIMIT is applied to all resultant rows.
query += " LIMIT 1"
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
query, args = expandQuery(query, args)
rows, err := a.db.Query(ctx, query, args...)
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.
var id int
err := a.db.QueryRow(ctx,
`INSERT INTO im_messages(createdAt,updatedAt,seqid,topic,"from",head,content) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
store.DecodeUid(t.ParseUid(msg.From)), msg.Head, common.ToJSON(msg.Content)).Scan(&id)
if err == nil {
// Replacing ID given by store by ID given by the DB.
msg.SetUid(t.Uid(id))
}
return err
}
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 > 0 {
// 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()
}
query, args := expandQuery(`SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m."from",m.head,m.content`+
" FROM im_messages AS m LEFT JOIN im_dellog AS d"+
" ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=?"+
" WHERE m.delid=0 AND m.topic=? "+seqIdConstraint+" AND d.deletedfor IS NULL"+
" ORDER BY m.seqid DESC LIMIT ?", args...)
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
for rows.Next() {
var msg t.Message
var from int64
if err = rows.Scan(&msg.CreatedAt, &msg.UpdatedAt, &msg.DeletedAt, &msg.DelId, &msg.SeqId,
&msg.Topic, &from, &msg.Head, &msg.Content); err != nil {
break
}
msg.From = store.EncodeUid(from).String()
msgs = append(msgs, msg)
}
if err == nil {
err = rows.Err()
}
return msgs, err
}
// Get ranges of deleted im_messages
func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
var limit = a.maxResults
var lower = 0
var upper = 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.Query(ctx, "SELECT topic,deletedfor,delid,low,hi FROM im_dellog WHERE topic=$1 AND delid BETWEEN $2 AND $3"+
" AND (deletedFor=0 OR deletedFor=$4) ORDER BY delid LIMIT $5",
topic, lower, upper, store.DecodeUid(forUser), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var im_dellog struct {
Topic string
Deletedfor int64
Delid int
Low int
Hi int
}
var dmsgs []t.DelMessage
var dmsg t.DelMessage
for rows.Next() {
if err = rows.Scan(&im_dellog.Topic, &im_dellog.Deletedfor, &im_dellog.Delid, &im_dellog.Low, &im_dellog.Hi); err != nil {
dmsgs = nil
break
}
if im_dellog.Delid != dmsg.DelId {
if dmsg.DelId > 0 {
dmsgs = append(dmsgs, dmsg)
}
dmsg.DelId = im_dellog.Delid
dmsg.Topic = im_dellog.Topic
if im_dellog.Deletedfor > 0 {
dmsg.DeletedFor = store.EncodeUid(im_dellog.Deletedfor).String()
} else {
dmsg.DeletedFor = ""
}
dmsg.SeqIdRanges = nil
}
if im_dellog.Hi <= im_dellog.Low+1 {
im_dellog.Hi = 0
}
dmsg.SeqIdRanges = append(dmsg.SeqIdRanges, t.Range{Low: im_dellog.Low, Hi: im_dellog.Hi})
}
if err == nil {
err = rows.Err()
}
if err == nil {
if dmsg.DelId > 0 {
dmsgs = append(dmsgs, dmsg)
}
}
return dmsgs, err
}
func messageDeleteList(ctx context.Context, tx pgx.Tx, topic string, toDel *t.DelMessage) error {
var err error
if toDel == nil {
// Whole topic is being deleted, thus also deleting all im_messages.
_, err = tx.Exec(ctx, "DELETE FROM im_dellog WHERE topic=$1", topic)
if err == nil {
_, err = tx.Exec(ctx, "DELETE FROM im_messages WHERE topic=$1", topic)
}
// im_filemsglinks will be deleted because of ON DELETE CASCADE
return err
}
// Only some im_messages are being deleted
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
// Hard-deleting im_messages requires updates to the im_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 im_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 []int
query, newargs := expandQuery("SELECT seqid FROM im_messages AS m WHERE "+where, args)
rows, err := tx.Query(ctx, query, newargs...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var seqID int
if err := rows.Scan(&seqID); err != nil {
return err
}
seqIDs = append(seqIDs, seqID)
}
if err = rows.Err(); 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.Ints(seqIDs)
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.
query, newargs = expandQuery("DELETE FROM im_filemsglinks AS fml USING im_messages AS m WHERE m.id=fml.msgid AND "+
where, args...)
_, err = tx.Exec(ctx, query, newargs...)
if err != nil {
return err
}
query, newargs = expandQuery(`UPDATE im_messages AS m SET deletedat=?,delid=?,"from"=0,head=NULL,content=NULL WHERE `+
where, t.TimeNow(), toDel.DelId, args)
_, err = tx.Exec(ctx, query, newargs...)
if err != nil {
return err
}
}
// Now make log entries. Needed for both hard- and soft-deleting.
// Prepare statement is not needed because the driver prepares the statement on first use then caches it.
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range toDel.SeqIdRanges {
if rng.Hi == 0 {
// Dellog must contain valid Low and *Hi*.
rng.Hi = rng.Low + 1
}
if _, err = tx.Exec(ctx, "INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES($1,$2,$3,$4,$5)",
topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
break
}
}
return err
}
// MessageDeleteList deletes im_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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
if err = messageDeleteList(ctx, tx, topic, toDel); err != nil {
return err
}
return tx.Commit(ctx)
}
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
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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
// Ensure uniqueness of the device ID: delete all records of the device ID
_, err = tx.Exec(ctx, "DELETE FROM im_devices WHERE hash=$1", hash)
if err != nil {
return err
}
// Actually add/update DeviceId for the new user
_, err = tx.Exec(ctx, "INSERT INTO im_devices(userid, hash, deviceId, platform, lastseen, lang) VALUES($1,$2,$3,$4,$5,$6)",
store.DecodeUid(uid), hash, def.DeviceId, def.Platform, def.LastSeen, def.Lang)
if err != nil {
return err
}
return tx.Commit(ctx)
}
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))
}
query, unums := expandQuery("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.Query(ctx, query, 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.Scan(&device.Userid, &device.Deviceid, &device.Platform, &device.Lastseen, &device.Lang); 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(ctx context.Context, tx pgx.Tx, uid t.Uid, deviceID string) error {
var err error
var res pgconn.CommandTag
if deviceID == "" {
res, err = tx.Exec(ctx, "DELETE FROM im_devices WHERE userid=$1", store.DecodeUid(uid))
} else {
res, err = tx.Exec(ctx, "DELETE FROM im_devices WHERE userid=$1 AND hash=$2", store.DecodeUid(uid), deviceHasher(deviceID))
}
if err == nil {
if count := res.RowsAffected(); count == 0 {
err = t.ErrNotFound
}
}
return err
}
func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
err = deviceDelete(ctx, tx, uid, deviceID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// 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
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return false, err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
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.QueryRow(ctx, "SELECT done FROM im_credentials WHERE synthetic=$1", synth).Scan(&done)
if err == nil {
// Assign err to ensure closing of a transaction.
err = t.ErrDuplicate
return false, err
}
if err != pgx.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(ctx, "UPDATE im_credentials SET deletedat=$1 WHERE userid=$2 AND method=$3 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(ctx, "UPDATE im_credentials SET updatedat=$1,deletedat=NULL,resp=$2,done=FALSE WHERE synthetic=$3",
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(ctx)
}
} else {
// Hard-deleting unconformed record if it exists.
_, err = tx.Exec(ctx, "DELETE FROM im_credentials WHERE synthetic=$1", cred.User+":"+synth)
if err != nil {
return false, err
}
}
_, err = tx.Exec(ctx, "INSERT INTO im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES($1,$2,$3,$4,$5,$6,$7,$8)",
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
}
return true, tx.Commit(ctx)
}
// 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(ctx context.Context, tx pgx.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)
}
}
where, _ := expandQuery(constraints, args...)
var err error
var res pgconn.CommandTag
if method == "" {
// Case 1
res, err = tx.Exec(ctx, "DELETE FROM im_credentials"+where, args...)
if err == nil {
if count := res.RowsAffected(); count == 0 {
err = t.ErrNotFound
}
}
return err
}
// Case 2.1
res, err = tx.Exec(ctx, "DELETE FROM im_credentials"+where+" AND (done=TRUE OR retries=0)", args...)
if err != nil {
return err
}
if count := res.RowsAffected(); count > 0 {
return nil
}
// Case 2.2
query, args := expandQuery("UPDATE im_credentials SET deletedat=?"+constraints, t.TimeNow(), args)
res, err = tx.Exec(ctx, query, args...)
if err == nil {
if count := res.RowsAffected(); count >= 0 {
err = t.ErrNotFound
}
}
return err
}
// CredDel deletes either im_credentials of the given user. If method is blank all
// im_credentials are removed. If value is blank all im_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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
err = credDel(ctx, tx, uid, method, value)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// 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.Exec(
ctx,
"UPDATE im_credentials SET updatedat=$1,done=TRUE,synthetic=CONCAT(method,':',value) "+
"WHERE userid=$2 AND method=$3 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
}
// 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.Exec(ctx, "UPDATE im_credentials SET updatedat=$1,retries=retries+1 WHERE userid=$2 AND method=$3 AND done=FALSE",
t.TimeNow(), 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.QueryRow(ctx, "SELECT createdat,updatedat,method,value,resp,done,retries "+
"FROM im_credentials WHERE userid=$1 AND deletedat IS NULL AND method=$2 AND done=FALSE",
store.DecodeUid(uid), method).Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries)
if err != nil {
if err == pgx.ErrNoRows {
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=$1 AND deletedat IS NULL"
args := []any{store.DecodeUid(uid)}
if method != "" {
query += " AND method=$2"
args = append(args, method)
}
if validatedOnly {
query += " AND done=TRUE"
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var im_credentials []t.Credential
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var cred t.Credential
if err = rows.Scan(&cred.CreatedAt, &cred.UpdatedAt, &cred.Method, &cred.Value, &cred.Resp, &cred.Done, &cred.Retries); err != nil {
im_credentials = nil
break
}
im_credentials = append(im_credentials, cred)
}
user := uid.String()
for i := range im_credentials {
im_credentials[i].User = user
}
return im_credentials, err
}
// 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))
}
_, err := a.db.Exec(ctx,
"INSERT INTO im_fileuploads(id,createdat,updatedat,userid,status,mimetype,size,etag,location) "+
"VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)",
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.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
now := t.TimeNow()
if success {
_, err = tx.Exec(ctx, "UPDATE im_fileuploads SET updatedat=$1,status=$2,size=$3,etag=$4,location=$5 WHERE id=$6",
now, t.UploadCompleted, size, fd.ETag, fd.Location, store.DecodeUid(fd.Uid()))
if err != nil {
return nil, err
}
fd.Status = t.UploadCompleted
fd.Size = size
} else {
// Deleting the record: there is no value in keeping it in the DB.
_, err = tx.Exec(ctx, "DELETE FROM im_fileuploads WHERE id=$1", store.DecodeUid(fd.Uid()))
if err != nil {
return nil, err
}
fd.Status = t.UploadFailed
fd.Size = 0
}
fd.UpdatedAt = now
return fd, tx.Commit(ctx)
}
// FileGet fetches a record of a specific file
func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
id := t.ParseUid(fid)
if id.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var fd t.FileDef
var ID int64
var userId int64
err := a.db.QueryRow(ctx, "SELECT id,createdat,updatedat,userid AS user,status,mimetype,size,etag,location "+
"FROM im_fileuploads WHERE id=$1", store.DecodeUid(id)).Scan(&ID, &fd.CreatedAt, &fd.UpdatedAt, &userId, &fd.Status,
&fd.MimeType, &fd.Size, &fd.ETag, &fd.Location)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
fd.Id = common.EncodeUidString(fd.Id).String()
fd.User = store.EncodeUid(userId).String()
return &fd, nil
}
// FileDeleteUnused deletes file upload records.
func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
// 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)
}
query, _ = expandQuery(query, args...)
rows, err := tx.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var locations []string
var ids []any
for rows.Next() {
var id int
var loc string
if err = rows.Scan(&id, &loc); err != nil {
break
}
if loc != "" {
locations = append(locations, loc)
}
ids = append(ids, id)
}
if err == nil {
err = rows.Err()
}
if err != nil {
return nil, err
}
if len(ids) > 0 {
query, ids = expandQuery("DELETE FROM im_fileuploads WHERE id IN (?)", ids)
_, err = tx.Exec(ctx, query, ids...)
if err != nil {
return nil, err
}
}
return locations, tx.Commit(ctx)
}
// FileLinkAttachments connects given topic or message to the file record IDs from the list.
func (a *adapter) FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error {
if len(fids) == 0 || (topic == "" && msgId.IsZero() && userId.IsZero()) {
return t.ErrMalformed
}
now := t.TimeNow()
var args []any
var linkId any
var linkBy string
if !msgId.IsZero() {
linkBy = "msgid"
linkId = int64(msgId)
} else if topic != "" {
linkBy = "topic"
linkId = topic
// Only one attachment per topic is permitted at this time.
fids = fids[0:1]
} else {
linkBy = "userid"
linkId = store.DecodeUid(userId)
// Only one attachment per user is permitted at this time.
fids = fids[0:1]
}
// Decoded ids
var dids []any
for _, fid := range fids {
id := t.ParseUid(fid)
if id.IsZero() {
return t.ErrMalformed
}
dids = append(dids, store.DecodeUid(id))
}
for _, id := range dids {
// createdat,fileid,[msgid|topic|userid]
args = append(args, now, id, linkId)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
// 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 + "=$1"
_, err = tx.Exec(ctx, sql, linkId)
if err != nil {
return err
}
}
query, args := expandQuery("INSERT INTO im_filemsglinks(createdat,fileid,"+linkBy+") VALUES (?,?,?)"+
strings.Repeat(",(?,?,?)", len(dids)-1), args...)
_, err = tx.Exec(ctx, query, args...)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// 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.QueryRow(ctx, `SELECT "value" FROM im_kvmeta WHERE "key"=$1 LIMIT 1`, key).Scan(&value); err != nil {
if err == pgx.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 = ` ON CONFLICT ("key") DO UPDATE SET createdat=$2,"value"=$3`
}
_, err := a.db.Exec(ctx, `INSERT INTO im_kvmeta("key",createdat,"value") VALUES($1,$2,$3)`+action,
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.Exec(ctx, `DELETE FROM im_kvmeta WHERE "key"=$1`, 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.Exec(ctx, `DELETE FROM im_kvmeta WHERE "key" LIKE $1 AND createdat<$2`, 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
}
msg := err.Error()
return strings.Contains(msg, "SQLSTATE 23505")
}
func isMissingTable(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "SQLSTATE 42P01")
}
func isMissingDb(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "SQLSTATE 3D000")
}
// setConnStr converts a config structure to a DSN connection string.
func setConnStr(c configType) (string, error) {
// Default to disable SSL mode.
sslMode := "disable"
if c.SSLMode != "" {
sslMode = c.SSLMode
}
if c.User == "" || c.Passwd == "" || c.Host == "" || c.Port == "" || c.DBName == "" {
return "", errors.New("adapter postgres invalid config value")
}
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s&connect_timeout=%d",
c.User,
c.Passwd,
c.Host,
c.Port,
c.DBName,
sslMode,
c.SqlTimeout)
return connStr, nil
}
// expandQuery replaces the placeholders in the query with the actual values and returns
// the expanded query and the arguments to be used in the query.
func expandQuery(query string, args ...any) (string, []any) {
var expandedArgs []any
var expandedQuery string
if len(args) != strings.Count(query, "?") {
args = flattenSlice(args)
}
expandedQuery, expandedArgs, _ = sqlx.In(query, args...)
return sqlx.Rebind(sqlx.DOLLAR, expandedQuery), expandedArgs
}
// flatMap converts a slice of mixed values/slices into a flat slice.
func flattenSlice(slice []any) []any {
var result []any
for _, v := range slice {
switch reflect.TypeOf(v).Kind() {
case reflect.Slice:
s := reflect.ValueOf(v)
for i := 0; i < s.Len(); i++ {
result = append(result, s.Index(i).Interface())
}
default:
result = append(result, v)
}
}
return result
}
// Rebind a query from ? to the target $ with custom initial value.
func rebindWithStart(query string, startAt int) string {
// Add space enough for 10 params before we have to allocate
rqb := make([]byte, 0, len(query)+10)
var i, j = 0, startAt
for i = strings.Index(query, "?"); i != -1; i = strings.Index(query, "?") {
rqb = append(rqb, query[:i]...)
rqb = append(rqb, '$')
rqb = strconv.AppendInt(rqb, int64(j), 10)
j++
query = query[i+1:]
}
return string(append(rqb, query...))
}
// GetTestAdapter returns an adapter object. Useful for running tests.
func GetTestAdapter() *adapter {
return &adapter{}
}
func init() {
store.RegisterAdapter(&adapter{})
}