Files
im_server/server/db/mongodb/adapter.go
T
2026-08-11 15:59:57 +08:00

3160 lines
86 KiB
Go

//go:build mongodb
// Package mongodb is a database adapter for MongoDB.
package mongodb
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"slices"
"sort"
"strconv"
"strings"
"time"
"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"
b "go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
mdb "go.mongodb.org/mongo-driver/mongo"
mdbopts "go.mongodb.org/mongo-driver/mongo/options"
)
// adapter holds MongoDB connection data.
type adapter struct {
conn *mdb.Client
db *mdb.Database
dbName string
// Maximum number of records to return
maxResults int
// Maximum number of message records to return
maxMessageResults int
version int
ctx context.Context
useTransactions bool
}
const (
adpVersion = 116
adapterName = "mongodb"
defaultHost = "localhost:27017"
defaultDatabase = "tinode"
defaultMaxResults = 1024
// This is capped by the Session's send queue limit (128).
defaultMaxMessageResults = 100
defaultAuthMechanism = "SCRAM-SHA-256"
defaultAuthSource = "admin"
)
// See https://godoc.org/go.mongodb.org/mongo-driver/mongo/options#ClientOptions for explanations.
type configType struct {
// Connection string URI https://www.mongodb.com/docs/manual/reference/connection-string/
Uri string `json:"uri,omitempty"`
Addresses any `json:"addresses,omitempty"`
ConnectTimeout int `json:"timeout,omitempty"`
// Options separately from ClientOptions (custom options):
Database string `json:"database,omitempty"`
ReplicaSet string `json:"replica_set,omitempty"`
AuthMechanism string `json:"auth_mechanism,omitempty"`
AuthSource string `json:"auth_source,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
UseTLS bool `json:"tls,omitempty"`
TlsCertFile string `json:"tls_cert_file,omitempty"`
TlsPrivateKey string `json:"tls_private_key,omitempty"`
InsecureSkipVerify bool `json:"tls_skip_verify,omitempty"`
// The only version supported at this time is "1".
APIVersion mdbopts.ServerAPIVersion `json:"api_version,omitempty"`
}
func (a *adapter) maybeStartTransaction(sess mdb.Session) error {
if a.useTransactions {
return sess.StartTransaction()
}
return nil
}
func (a *adapter) maybeCommitTransaction(ctx context.Context, sess mdb.Session) error {
if a.useTransactions {
return sess.CommitTransaction(ctx)
}
return nil
}
// Open initializes mongodb session
func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.conn != nil {
return errors.New("adapter mongodb is already connected")
}
if len(jsonconfig) < 2 {
return errors.New("adapter mongodb missing config")
}
var err error
var config configType
if err = json.Unmarshal(jsonconfig, &config); err != nil {
return errors.New("adapter mongodb failed to parse config: " + err.Error())
}
var opts mdbopts.ClientOptions
if config.Addresses == nil {
opts.SetHosts([]string{defaultHost})
} else if host, ok := config.Addresses.(string); ok {
opts.SetHosts([]string{host})
} else if ihosts, ok := config.Addresses.([]any); ok && len(ihosts) > 0 {
hosts := make([]string, len(ihosts))
for i, ih := range ihosts {
h, ok := ih.(string)
if !ok || h == "" {
return errors.New("adapter mongodb invalid config.Addresses value")
}
hosts[i] = h
}
opts.SetHosts(hosts)
} else {
return errors.New("adapter mongodb failed to parse config.Addresses")
}
if config.Database == "" {
a.dbName = defaultDatabase
} else {
a.dbName = config.Database
}
if config.ReplicaSet != "" {
opts.SetReplicaSet(config.ReplicaSet)
a.useTransactions = true
} else {
// Retriable writes are not supported in a standalone instance.
opts.SetRetryWrites(false)
}
if config.Username != "" {
if config.AuthMechanism == "" {
config.AuthMechanism = defaultAuthMechanism
}
if config.AuthSource == "" {
config.AuthSource = defaultAuthSource
}
var passwordSet bool
if config.Password != "" {
passwordSet = true
}
opts.SetAuth(
mdbopts.Credential{
AuthMechanism: config.AuthMechanism,
AuthSource: config.AuthSource,
Username: config.Username,
Password: config.Password,
PasswordSet: passwordSet,
})
}
if config.UseTLS {
tlsConfig := tls.Config{
InsecureSkipVerify: config.InsecureSkipVerify,
}
if config.TlsCertFile != "" {
cert, err := tls.LoadX509KeyPair(config.TlsCertFile, config.TlsPrivateKey)
if err != nil {
return err
}
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
}
opts.SetTLSConfig(&tlsConfig)
}
if a.maxResults <= 0 {
a.maxResults = defaultMaxResults
}
if a.maxMessageResults <= 0 {
a.maxMessageResults = defaultMaxMessageResults
}
// Connection string URI overrides any other options configured earlier.
if config.Uri != "" {
opts.ApplyURI(config.Uri)
}
if config.APIVersion != "" {
opts.SetServerAPIOptions(mdbopts.ServerAPI(config.APIVersion))
}
// Make sure the options are sane.
if err = opts.Validate(); err != nil {
return err
}
a.ctx = context.Background()
a.conn, err = mdb.Connect(a.ctx, &opts)
a.db = a.conn.Database(a.dbName)
if err != nil {
return err
}
a.version = -1
return nil
}
// Close the adapter
func (a *adapter) Close() error {
var err error
if a.conn != nil {
err = a.conn.Disconnect(a.ctx)
a.conn = nil
a.version = -1
}
return err
}
// IsOpen checks if the adapter is ready for use
func (a *adapter) IsOpen() bool {
return a.conn != nil
}
// GetDbVersion returns current database version.
func (a *adapter) GetDbVersion() (int, error) {
if a.version > 0 {
return a.version, nil
}
var result struct {
Key string `bson:"_id"`
Value int
}
if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": "version"}).Decode(&result); err != nil {
if err == mdb.ErrNoDocuments {
err = errors.New("Database not initialized")
}
return -1, err
}
a.version = result.Value
return result.Value, nil
}
func (a *adapter) updateDbVersion(v int) error {
a.version = -1
_, err := a.db.Collection("kvmeta").UpdateOne(a.ctx,
b.M{"_id": "version"},
b.M{"$set": b.M{"value": v}},
)
return err
}
// CheckDbVersion checks if the actual database version matches adapter version.
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 (a *adapter) Version() int {
return adpVersion
}
// DB connection stats object.
func (a *adapter) Stats() any {
if a.db == nil {
return nil
}
var result b.M
if err := a.db.RunCommand(a.ctx, b.D{{"serverStatus", 1}}, nil).Decode(&result); err != nil {
return nil
}
return result["connections"]
}
// GetName returns the name of the adapter
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 creates the database optionally dropping an existing database first.
func (a *adapter) CreateDb(reset bool) error {
if reset {
if err := a.db.Drop(a.ctx); err != nil {
return err
}
} else if a.isDbInitialized() {
return errors.New("Database already initialized")
}
// Collections (tables) do not need to be explicitly created since MongoDB creates them with first write operation
indexes := []struct {
Collection string
Field string
IndexOpts mdb.IndexModel
}{
// Users
// Index on 'user.state' for finding suspended and soft-deleted users.
{
Collection: "users",
Field: "state",
},
// Index on 'user.tags' array so user can be found by tags.
{
Collection: "users",
Field: "tags",
},
// Index for 'user.devices.deviceid' to ensure Device ID uniqueness across users.
// Partial filter set to avoid unique constraint for null values (when user object have no devices).
{
Collection: "users",
IndexOpts: mdb.IndexModel{
Keys: b.M{"devices.deviceid": 1},
Options: mdbopts.Index().
SetUnique(true).
SetPartialFilterExpression(b.M{"devices.deviceid": b.M{"$exists": true}}),
},
},
// Index on lastSeen and updatedat for deleting stale user accounts.
{
Collection: "users",
IndexOpts: mdb.IndexModel{Keys: b.D{{"lastseen", 1}, {"updatedat", 1}}},
},
// User authentication records {_id, userid, secret}
// Should be able to access user's auth records by user id
{
Collection: "auth",
Field: "userid",
},
// Subscription to a topic. The primary key is a topic:user string
{
Collection: "subscriptions",
Field: "user",
},
{
Collection: "subscriptions",
Field: "topic",
},
// Topics stored in database
// Index on 'owner' field for deleting users.
{
Collection: "topics",
Field: "owner",
},
// Index on 'state' for finding suspended and soft-deleted topics.
{
Collection: "topics",
Field: "state",
},
// Index on 'topic.tags' array so topics can be found by tags.
// These tags are not unique as opposite to 'user.tags'.
{
Collection: "topics",
Field: "tags",
},
// Stored message
// Compound index of 'topic - seqid' for selecting messages in a topic.
{
Collection: "messages",
IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"seqid", 1}}},
},
// Compound index of hard-deleted messages
{
Collection: "messages",
IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"delid", 1}}},
},
// Compound multi-index of soft-deleted messages: each message gets multiple compound index entries like
// [topic, user1, delid1], [topic, user2, delid2],...
{
Collection: "messages",
IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"deletedfor.user", 1}, {"deletedfor.delid", 1}}},
},
// Log of deleted messages
// Compound index of 'topic - delid'
{
Collection: "dellog",
IndexOpts: mdb.IndexModel{Keys: b.D{{"topic", 1}, {"delid", 1}}},
},
// User credentials - contact information such as "email:jdoe@example.com" or "tel:+18003287448":
// Id: "method:credential" like "email:jdoe@example.com". See types.Credential.
// Index on 'credentials.user' to be able to query credentials by user id.
{
Collection: "credentials",
Field: "user",
},
// Records of file uploads. See types.FileDef.
// Index on 'fileuploads.usecount' to be able to delete unused records at once.
{
Collection: "fileuploads",
Field: "usecount",
},
}
var err error
for _, idx := range indexes {
if idx.Field != "" {
_, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{idx.Field: 1}})
} else {
_, err = a.db.Collection(idx.Collection).Indexes().CreateOne(a.ctx, idx.IndexOpts)
}
if err != nil {
return err
}
}
// Collection "kvmeta" with metadata key-value pairs.
// Key in "_id" field.
// Record current DB version.
if _, err := a.db.Collection("kvmeta").InsertOne(a.ctx, map[string]any{"_id": "version", "value": adpVersion}); err != nil {
return err
}
// Create system topic 'sys'.
return createSystemTopic(a)
}
// UpgradeDb upgrades database to the current adapter version.
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
}
_, err := a.GetDbVersion()
if err != nil {
return err
}
if a.version == 110 {
// Perform database upgrade from versions 110 to version 111.
// Users
// Reset previously unused field State to value StateOK.
if _, err := a.db.Collection("users").UpdateMany(a.ctx,
b.M{},
b.M{"$set": b.M{"state": t.StateOK}}); err != nil {
return err
}
// Add StatusDeleted to all deleted users as indicated by DeletedAt not being null.
if _, err := a.db.Collection("users").UpdateMany(a.ctx,
b.M{"deletedat": b.M{"$ne": nil}},
b.M{"$set": b.M{"state": t.StateDeleted}}); err != nil {
return err
}
// Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.
if _, err := a.db.Collection("users").UpdateMany(a.ctx,
b.M{"deletedat": b.M{"$exists": true}},
b.M{"$rename": b.M{"deletedat": "stateat"}}); err != nil {
return err
}
// Drop secondary index DeletedAt.
if _, err := a.db.Collection("users").Indexes().DropOne(a.ctx, "deletedat_1"); err != nil {
return err
}
// Create secondary index on State for finding suspended and soft-deleted topics.
if _, err = a.db.Collection("users").Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{"state": 1}}); err != nil {
return err
}
// Topics
// Add StateDeleted to all topics with DeletedAt not null.
if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
b.M{"deletedat": b.M{"$ne": nil}},
b.M{"$set": b.M{"state": t.StateDeleted}}); err != nil {
return err
}
// Set StateOK for all other topics.
if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
b.M{"state": b.M{"$exists": false}},
b.M{"$set": b.M{"state": t.StateOK}}); err != nil {
return err
}
// Rename DeletedAt into StateAt. Update only those rows which have defined DeletedAt.
if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
b.M{"deletedat": b.M{"$exists": true}},
b.M{"$rename": b.M{"deletedat": "stateat"}}); err != nil {
return err
}
// Create secondary index on State for finding suspended and soft-deleted topics.
if _, err = a.db.Collection("topics").Indexes().CreateOne(a.ctx, mdb.IndexModel{Keys: b.M{"state": 1}}); err != nil {
return err
}
if err := bumpVersion(a, 111); err != nil {
return err
}
}
if a.version == 111 {
// Just bump the version to keep in line with MySQL.
if err := bumpVersion(a, 112); err != nil {
return err
}
}
if a.version == 112 {
// Create secondary index on Users(lastseen,updatedat) for deleting stale user accounts.
if _, err = a.db.Collection("users").Indexes().CreateOne(a.ctx,
mdb.IndexModel{Keys: b.D{{"lastseen", 1}, {"updatedat", 1}}}); err != nil {
return err
}
if err := bumpVersion(a, 113); err != nil {
return err
}
}
if a.version < 116 {
// Version 114: topics.aux added, fileuploads.etag added.
// Version 115: SQL indexes added.
// Version 116: topics.subcnt added.
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
}
// Create system topic 'sys'.
func createSystemTopic(a *adapter) error {
now := t.TimeNow()
_, err := a.db.Collection("topics").InsertOne(a.ctx, &t.Topic{
ObjHeader: t.ObjHeader{
Id: "sys",
CreatedAt: now,
UpdatedAt: now},
TouchedAt: now,
Access: t.DefaultAccess{Auth: t.ModeNone, Anon: t.ModeNone},
Public: map[string]any{"fn": "System"},
})
return err
}
// User management
// UserCreate creates user record
func (a *adapter) UserCreate(usr *t.User) error {
if _, err := a.db.Collection("users").InsertOne(a.ctx, &usr); err != nil {
return err
}
return nil
}
// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)
func (a *adapter) UserGet(id t.Uid) (*t.User, error) {
var user t.User
filter := b.M{"_id": id.String(), "state": b.M{"$ne": t.StateDeleted}}
if err := a.db.Collection("users").FindOne(a.ctx, filter).Decode(&user); err != nil {
if err == mdb.ErrNoDocuments { // User not found
return nil, nil
} else {
return nil, err
}
}
user.Public = unmarshalBsonD(user.Public)
user.Trusted = unmarshalBsonD(user.Trusted)
return &user, nil
}
// UserGetAll returns user records for a given list of user IDs
func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
uids := make([]any, len(ids))
for i, id := range ids {
uids[i] = id.String()
}
var users []t.User
filter := b.M{"_id": b.M{"$in": uids}, "state": b.M{"$ne": t.StateDeleted}}
cur, err := a.db.Collection("users").Find(a.ctx, filter)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
for cur.Next(a.ctx) {
var user t.User
if err := cur.Decode(&user); err != nil {
return nil, err
}
user.Public = unmarshalBsonD(user.Public)
user.Trusted = unmarshalBsonD(user.Trusted)
users = append(users, user)
}
return users, nil
}
// UserDelete deletes specified user: wipes completely (hard-delete) or marks as deleted.
func (a *adapter) UserDelete(uid t.Uid, hard bool) error {
ownFilter := b.M{"owner": uid.String()}
// In case of hard delete, delete all topics, even those which were
// soft-deleted previsously.
if !hard {
ownFilter["state"] = b.M{"$ne": t.StateDeleted}
}
forUser := uid.String()
// Select topics where the user is the owner.
ownTopics, err := a.topicNamesForUser("topics", ownFilter, "_id", true)
if err != nil {
return err
}
ownTopicsFilter := b.M{"topic": b.M{"$in": ownTopics}}
var sess mdb.Session
if sess, err = a.conn.StartSession(); err != nil {
return err
}
defer sess.EndSession(a.ctx)
if err = a.maybeStartTransaction(sess); err != nil {
return err
}
if err = mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error {
if hard {
// No need to delete user's devices: devices are stored in user's record and will be deleted with it.
// Delete user's subscriptions in all topics and decrement subcnt in topic.
if err = a.subsDelete(sc, b.M{"user": forUser}, true); err != nil {
return err
}
// Delete user's dellog entries in all topics.
err = a.clearUserDellog(sc, forUser)
if err != nil {
return err
}
// Can't delete user's messages in all topics because we cannot notify topics of such deletion.
// Just leave the messages there marked as sent by "not found" user.
// Delete topics where the user is the owner:
if len(ownTopics) > 0 {
// 1. Delete dellog
// 2. Decrement fileuploads.
// 3. Delete all messages.
// 4. Delete subscriptions.
// Delete dellog for topics owned by the user.
_, err = a.db.Collection("dellog").DeleteMany(sc, ownTopicsFilter)
if err != nil {
return err
}
// Decrement fileuploads UseCounter
// First get array of attachments IDs that were used in messages of topics from topicIds
// Then decrement the usecount field of these file records
err = a.decFileUseCounter(sc, "messages", ownTopicsFilter)
if err != nil {
return err
}
// Decrement use counter for topic avatars.
err = a.decFileUseCounter(sc, "topics", b.M{"_id": b.M{"$in": ownTopics}})
if err != nil {
return err
}
// Delete messages
_, err = a.db.Collection("messages").DeleteMany(sc, ownTopicsFilter)
if err != nil {
return err
}
// Delete subscriptions for all users where the user is the owner of the topic.
_, err = a.db.Collection("subscriptions").DeleteMany(sc, ownTopicsFilter)
if err != nil {
return err
}
// No need to delete topic tags: they are stored in topic record and will be deleted with it.
// And finally delete the topics.
if _, err = a.db.Collection("topics").DeleteMany(sc, b.M{"owner": forUser}); err != nil {
return err
}
}
// Delete user's authentication records.
if _, err = a.authDelAllRecords(sc, uid); err != nil {
return err
}
// Delete credentials.
if err = a.credDel(sc, uid, "", ""); err != nil && err != t.ErrNotFound {
return err
}
// Delete avatar (decrement use counter).
if err = a.decFileUseCounter(sc, "users", b.M{"_id": forUser}); err != nil {
return err
}
// No need to delete user's tags: they are stored in user's record and will be deleted with it.
// And finally delete the user.
if _, err = a.db.Collection("users").DeleteOne(sc, b.M{"_id": forUser}); err != nil {
return err
}
} else {
// Disable user's subscriptions.
if err = a.subsDelete(sc, b.M{"user": forUser}, false); err != nil {
return err
}
now := t.TimeNow()
disable := b.M{"$set": b.M{"updatedat": now, "state": t.StateDeleted, "stateat": now}}
if len(ownTopics) > 0 {
// Disable subscriptions for topics where the user is the owner.
if _, err = a.db.Collection("subscriptions").UpdateMany(sc, ownTopicsFilter, disable); err != nil {
return err
}
// Disable group topics where the user is the owner.
if _, err = a.db.Collection("topics").UpdateMany(sc, b.M{"_id": b.M{"$in": ownTopics}},
b.M{"$set": b.M{
"updatedat": now, "touchedat": now, "state": t.StateDeleted, "stateat": now,
}}); err != nil {
return err
}
}
// Disable p2p topics with the user.
p2pTopics, err := a.p2pTopicsForUser(uid)
if err != nil {
return err
}
if len(p2pTopics) > 0 {
if _, err = a.db.Collection("topics").UpdateMany(sc, b.M{"_id": b.M{"$in": p2pTopics}},
b.M{"$set": b.M{
"updatedat": now, "touchedat": now, "state": t.StateDeleted, "stateat": now,
}}); err != nil {
return err
}
// Disable subscription to user's disabled p2p topics.
if _, err = a.db.Collection("subscriptions").UpdateMany(sc,
b.M{"topic": b.M{"$in": p2pTopics}}, disable); err != nil {
return err
}
}
// Finally disable the user.
if _, err = a.db.Collection("users").UpdateMany(sc, b.M{"_id": forUser}, disable); err != nil {
return err
}
}
// Finally commit all changes
return a.maybeCommitTransaction(sc, sess)
}); err != nil {
return err
}
return err
}
// topicStateForUser is called by UserUpdate when the update contains state change.
// Soft-deleted topics remain soft-deleted.
func (a *adapter) topicStateForUser(uid t.Uid, now time.Time, update any) error {
state, ok := update.(t.ObjState)
if !ok {
return t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
// Change state of all topics where the user is the owner.
if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
b.M{"owner": uid.String(), "state": b.M{"$ne": t.StateDeleted}},
b.M{"$set": b.M{"state": state, "stateat": now}}); err != nil {
return err
}
// Change state of p2p topics with the user (p2p topic's owner is blank)
// Get list of p2p topics with the user.
p2pTopics, err := a.p2pTopicsForUser(uid)
if err != nil {
return err
}
if len(p2pTopics) > 0 {
if _, err := a.db.Collection("topics").UpdateMany(a.ctx,
b.M{"_id": b.M{"$in": p2pTopics}, "state": b.M{"$ne": t.StateDeleted}},
b.M{"$set": b.M{"state": state, "stateat": now}}); err != nil {
return err
}
}
// Subscriptions don't need to be updated:
// subscriptions of a disabled user are not disabled and still can be manipulated.
return nil
}
// UserUpdate updates user record
func (a *adapter) UserUpdate(uid t.Uid, update map[string]any) error {
// Convert field names from CamelCase to lowercase.
update = normalizeUpdateMap(update)
_, err := a.db.Collection("users").UpdateOne(a.ctx, b.M{"_id": uid.String()}, b.M{"$set": update})
if err != nil {
return err
}
if state, ok := update["state"]; ok {
now, _ := update["stateat"].(time.Time)
err = a.topicStateForUser(uid, now, state)
}
// Tags are stored in the same record, no need to update them separately.
return err
}
// UserUpdateTags adds, removes, or resets user's tags.
func (a *adapter) UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error) {
var newTags t.StringSlice
// Compare to nil vs checking for zero length: zero length reset is valid.
if reset != nil {
// Replace tags with the new value
newTags = reset
} else {
var user t.User
err := a.db.Collection("users").FindOne(a.ctx, b.M{"_id": uid.String()}).Decode(&user)
if err != nil {
return nil, err
}
// Mutate the tag list.
newTags = user.Tags
if len(add) > 0 {
newTags = union(newTags, add)
}
if len(remove) > 0 {
newTags = diff(newTags, remove)
}
}
return newTags, a.UserUpdate(uid, map[string]any{"tags": newTags})
}
// UserGetByCred returns user ID for the given validated credential.
func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) {
var userId map[string]string
err := a.db.Collection("credentials").FindOne(a.ctx,
b.M{"_id": method + ":" + value},
mdbopts.FindOne().SetProjection(b.M{"user": 1, "_id": 0}),
).Decode(&userId)
if err != nil {
if err == mdb.ErrNoDocuments {
return t.ZeroUid, nil
}
return t.ZeroUid, err
}
return t.ParseUid(userId["user"]), nil
}
// UserUnreadCount returns the total number of unread messages in all topics with
// the R permission. If read fails, the counts are still returned with the original
// user IDs but with the unread count undefined and non-nil error.
// Does not count unread messages in channels although it probably should.
func (a *adapter) UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error) {
uids := make([]string, len(ids))
counts := make(map[t.Uid]int, len(ids))
for i, id := range ids {
uids[i] = id.String()
// Ensure all original uids are always present.
counts[id] = 0
}
/*
Query:
db.subscriptions.aggregate([
{ $match: { user: { $in: ["KnElfSSA21U", "0ZcCQmwI2RI"] } } },
{ $lookup: { from: "topics", localField: "topic", foreignField: "_id", as: "fromTopics"} },
{ $match: { fromTopics: { $not: {$size: 0} }}},
{ $replaceRoot: { newRoot: { $mergeObjects: [ {$arrayElemAt: [ "$fromTopics", 0 ]} , "$$ROOT" ] } } },
{ $match: {
deletedat: { $exists: false },
state: { $ne: t.StateDeleted },
modewant: { $bitsAllSet: [ t.ModeRead ] },
modegiven: { $bitsAllSet: [ t.ModeRead ] }
}
},
{ $project: { _id: 0, user: 1, readseqid: 1, seqid: 1} },
{ $group: { _id: "$user", unreadCount: { $sum: { $subtract: [ "$seqid", "$readseqid" ] } } } }
])
Result:
{ "_id" : "KnElfSSA21U", "unreadCount" : 0 }
{ "_id" : "0ZcCQmwI2RI", "unreadCount" : 7 }
*/
pipeline := b.A{
b.M{"$match": b.M{"user": b.M{"$in": uids}}},
// Join documents from two collection.
// FIXME: this does not work for channels as localField[topic] is not the same as foreignField[_id].
b.M{"$lookup": b.M{
"from": "topics",
"localField": "topic",
"foreignField": "_id",
"as": "fromTopics"},
},
// Remove users with no subscriptions.
b.M{"$match": b.M{"fromTopics": b.M{"$not": b.M{"$size": 0}}}},
// Merge two documents into one
b.M{"$replaceRoot": b.M{"newRoot": b.M{"$mergeObjects": b.A{b.M{"$arrayElemAt": b.A{"$fromTopics", 0}}, "$$ROOT"}}}},
// Keep only those records which affect the result.
b.M{"$match": b.M{
"deletedat": b.M{"$exists": false},
"state": b.M{"$ne": t.StateDeleted},
// Filter by access mode
"modewant": b.M{"$bitsAllSet": b.A{t.ModeRead}},
"modegiven": b.M{"$bitsAllSet": b.A{t.ModeRead}}}},
// Remove unused fields.
b.M{"$project": b.M{"_id": 0, "user": 1, "readseqid": 1, "seqid": 1}},
// GROUP BY user.
b.M{"$group": b.M{"_id": "$user", "unreadCount": b.M{"$sum": b.M{"$subtract": b.A{"$seqid", "$readseqid"}}}}},
}
cur, err := a.db.Collection("subscriptions").Aggregate(a.ctx, pipeline)
if err != nil {
return counts, err
}
defer cur.Close(a.ctx)
for cur.Next(a.ctx) {
var oneCount struct {
Id string `bson:"_id"`
UnreadCount int `bson:"unreadCount"`
}
cur.Decode(&oneCount)
counts[t.ParseUid(oneCount.Id)] = oneCount.UnreadCount
}
return counts, nil
}
// UserGetUnvalidated returns a list of uids which have never logged in, have no
// validated credentials and haven't been updated since lastUpdatedBefore.
func (a *adapter) UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error) {
/*
Query:
[
// .. WHERE lastseen IS NULL AND updatedat<?
{$match: {
$and: [
{ lastseen: null },
{ updatedat: {$lt: new ISODate("2022-12-09T01:26:15.819Z")} },
],
}},
// JOIN credentials ON id=user
{$lookup: {
from: "credentials",
localField: "_id",
foreignField: "user",
as: "fcred",
}},
// {x: 1, y: [{a: 1}, {a: 2}]} -> [{x: 1, a: 1}, {x: 1, a: 2}]
{$unwind: {path: "$fcred"}},
// SELECT _id, CASE WHEN done THEN 1 ELSE 0 END
{$project: {
_id: 1,
completed: { $cond: { if: "$fcred.done", then: 1, else: 0 } },
}},
// GROUP BY _id
{$group: { _id: "$_id", completed: { $sum: "$completed" } } },
// HAVING completed=0
{$match: { completed: 0 }},
// SELECT _id
{$project: { _id: "$_id" }},
{$limit: 10}
]
*/
pipeline := b.A{
b.M{"$match": b.M{
"$and": b.A{
b.M{"lastseen": primitive.Null{}},
b.M{"updatedat": b.M{"$lt": lastUpdatedBefore}},
},
}},
b.M{"$lookup": b.D{
{"from", "credentials"},
{"localField", "_id"},
{"foreignField", "user"},
{"as", "fcred"}},
},
b.M{"$unwind": b.M{"path": "$fcred"}},
b.M{"$project": b.D{
{"_id", 1},
{"completed", b.M{
"$cond": b.D{{"if", "$fcred.done"}, {"then", 1}, {"else", 0}}},
}}},
b.M{"$group": b.D{{"_id", "$_id"}, {"completed", b.M{"$sum": "$completed"}}}},
b.M{"$match": b.M{"completed": 0}},
b.M{"$project": b.M{"_id": "$_id"}},
b.M{"$limit": limit},
}
cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var uids []t.Uid
for cur.Next(a.ctx) {
var oneUser struct {
Id string `bson:"_id"`
}
if err := cur.Decode(&oneUser); err != nil {
return nil, err
}
uid := t.ParseUid(oneUser.Id)
if uid.IsZero() {
return nil, errors.New("failed to decode user id")
}
uids = append(uids, uid)
}
return uids, err
}
// 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) {
credCollection := a.db.Collection("credentials")
cred.Id = cred.Method + ":" + cred.Value
if !cred.Done {
// Check if the same credential is already validated.
var result1 t.Credential
err := credCollection.FindOne(a.ctx, b.M{"_id": cred.Id}).Decode(&result1)
if result1 != (t.Credential{}) {
// Someone has already validated this credential.
return false, t.ErrDuplicate
}
if err != nil && err != mdb.ErrNoDocuments { // if no result -> continue
return false, err
}
// Soft-delete all unvalidated records of this user and method.
_, err = credCollection.UpdateMany(a.ctx,
b.M{"user": cred.User, "method": cred.Method, "done": false},
b.M{"$set": b.M{"deletedat": t.TimeNow()}})
if err != nil {
return false, err
}
// If credential is not confirmed, it should not block others
// from attempting to validate it: make index user-unique instead of global-unique.
cred.Id = cred.User + ":" + cred.Id
// Check if this credential has already been added by the user.
var result2 t.Credential
err = credCollection.FindOne(a.ctx, b.M{"_id": cred.Id}).Decode(&result2)
if result2 != (t.Credential{}) {
_, err = credCollection.UpdateOne(a.ctx,
b.M{"_id": cred.Id},
b.M{
"$unset": b.M{"deletedat": ""},
"$set": b.M{"updatedat": cred.UpdatedAt, "resp": cred.Resp}})
if err != nil {
return false, err
}
// The record was updated, all is fine.
return false, nil
}
if err != nil && err != mdb.ErrNoDocuments {
return false, err
}
} else {
// Hard-delete potentially present unvalidated credential.
_, err := credCollection.DeleteOne(a.ctx, b.M{"_id": cred.User + ":" + cred.Id})
if err != nil {
return false, err
}
}
// Insert a new record.
_, err := credCollection.InsertOne(a.ctx, cred)
if isDuplicateErr(err) {
return true, t.ErrDuplicate
}
return true, err
}
// CredGetActive returns the currently active credential record for the given method.
func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) {
var cred t.Credential
filter := b.M{
"user": uid.String(),
"deletedat": b.M{"$exists": false},
"method": method,
"done": false}
if err := a.db.Collection("credentials").FindOne(a.ctx, filter).Decode(&cred); err != nil {
if err == mdb.ErrNoDocuments { // Cred not found
err = nil
}
return nil, err
}
return &cred, nil
}
// CredGetAll returns credential records for the given user and method, validated only or all.
func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) {
filter := b.M{"user": uid.String()}
if method != "" {
filter["method"] = method
}
if validatedOnly {
filter["done"] = true
} else {
filter["deletedat"] = b.M{"$exists": false}
}
cur, err := a.db.Collection("credentials").Find(a.ctx, filter)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var credentials []t.Credential
if err := cur.All(a.ctx, &credentials); err != nil {
return nil, err
}
return credentials, nil
}
// CredDel deletes credentials for the given method/value. If method is empty, deletes all
// user's credentials.
func (a *adapter) credDel(ctx context.Context, uid t.Uid, method, value string) error {
credCollection := a.db.Collection("credentials")
filter := b.M{"user": uid.String()}
if method != "" {
filter["method"] = method
if value != "" {
filter["value"] = value
}
} else {
res, err := credCollection.DeleteMany(ctx, filter)
if err == nil {
if res.DeletedCount == 0 {
err = t.ErrNotFound
}
}
return err
}
// Hard-delete all confirmed values or values with no attempts at confirmation.
hardDeleteFilter := copyBsonMap(filter)
hardDeleteFilter["$or"] = b.A{
b.M{"done": true},
b.M{"retries": 0}}
if res, err := credCollection.DeleteMany(ctx, hardDeleteFilter); err != nil {
return err
} else if res.DeletedCount > 0 {
return nil
}
// Soft-delete all other values.
res, err := credCollection.UpdateMany(ctx, filter, b.M{"$set": b.M{"deletedat": t.TimeNow()}})
if err == nil {
if res.ModifiedCount == 0 {
err = t.ErrNotFound
}
}
return err
}
func (a *adapter) CredDel(uid t.Uid, method, value string) error {
return a.credDel(a.ctx, uid, method, value)
}
// CredConfirm marks given credential as validated.
func (a *adapter) CredConfirm(uid t.Uid, method string) error {
cred, err := a.CredGetActive(uid, method)
if err != nil {
return err
}
cred.Done = true
cred.UpdatedAt = t.TimeNow()
if _, err = a.CredUpsert(cred); err != nil {
return err
}
_, _ = a.db.Collection("credentials").DeleteOne(a.ctx, b.M{"_id": uid.String() + ":" + cred.Method + ":" + cred.Value})
return nil
}
// CredFail increments count of failed validation attepmts for the given credentials.
func (a *adapter) CredFail(uid t.Uid, method string) error {
filter := b.M{
"user": uid.String(),
"deletedat": b.M{"$exists": false},
"method": method,
"done": false}
update := b.M{
"$inc": b.M{"retries": 1},
"$set": b.M{"updatedat": t.TimeNow()}}
_, err := a.db.Collection("credentials").UpdateOne(a.ctx, filter, update)
return err
}
// Authentication management for the basic authentication scheme
// AuthGetUniqueRecord returns authentication record for a given unique value i.e. login.
func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error) {
var record struct {
UserId string
AuthLvl auth.Level
Secret []byte
Expires time.Time
}
filter := b.M{"_id": unique}
findOpts := mdbopts.FindOne().SetProjection(b.M{
"userid": 1,
"authlvl": 1,
"secret": 1,
"expires": 1,
})
if err := a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil {
if err == mdb.ErrNoDocuments {
return t.ZeroUid, 0, nil, time.Time{}, nil
}
return t.ZeroUid, 0, nil, time.Time{}, err
}
return t.ParseUid(record.UserId), record.AuthLvl, record.Secret, record.Expires, nil
}
// AuthGetRecord returns authentication record given user ID and method.
func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
var record struct {
Id string `bson:"_id"`
AuthLvl auth.Level
Secret []byte
Expires time.Time
}
filter := b.M{"userid": uid.String(), "scheme": scheme}
findOpts := mdbopts.FindOne().SetProjection(b.M{
"authlvl": 1,
"secret": 1,
"expires": 1,
})
err := a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record)
if err != nil {
if err == mdb.ErrNoDocuments {
err = t.ErrNotFound
}
return "", 0, nil, time.Time{}, err
}
return record.Id, record.AuthLvl, record.Secret, record.Expires, nil
}
// AuthAddRecord creates new authentication record
func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error {
authRecord := b.M{
"_id": unique,
"userid": uid.String(),
"scheme": scheme,
"authlvl": authLvl,
"secret": secret,
"expires": expires}
if _, err := a.db.Collection("auth").InsertOne(a.ctx, authRecord); err != nil {
if isDuplicateErr(err) {
return t.ErrDuplicate
}
return err
}
return nil
}
// AuthDelScheme deletes an existing authentication scheme for the user.
func (a *adapter) AuthDelScheme(uid t.Uid, scheme string) error {
_, err := a.db.Collection("auth").DeleteOne(a.ctx,
b.M{
"userid": uid.String(),
"scheme": scheme})
return err
}
func (a *adapter) authDelAllRecords(ctx context.Context, uid t.Uid) (int, error) {
res, err := a.db.Collection("auth").DeleteMany(ctx, b.M{"userid": uid.String()})
return int(res.DeletedCount), err
}
// AuthDelAllRecords deletes all records of a given user.
func (a *adapter) AuthDelAllRecords(uid t.Uid) (int, error) {
return a.authDelAllRecords(a.ctx, uid)
}
// AuthUpdRecord modifies an authentication record.
func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string,
authLvl auth.Level, secret []byte, expires time.Time) error {
// The primary key is immutable. If '_id' has changed, we have to replace the old record with a new one:
// 1. Check if '_id' has changed.
// 2. If not, execute update by '_id'
// 3. If yes, first insert the new record (it may fail due to dublicate '_id') then delete the old one.
var err error
var record common.AuthRecord
findOpts := mdbopts.FindOne().SetProjection(b.M{"_id": 1})
filter := b.M{"userid": uid.String(), "scheme": scheme}
if err = a.db.Collection("auth").FindOne(a.ctx, filter, findOpts).Decode(&record); err != nil {
if err == mdb.ErrNoDocuments {
err = t.ErrNotFound
}
return err
}
if record.Unique == unique {
upd := b.M{
"authlvl": authLvl,
}
if len(secret) > 0 {
upd["secret"] = secret
}
if !expires.IsZero() {
upd["expires"] = expires
}
_, err = a.db.Collection("auth").UpdateOne(a.ctx,
b.M{"_id": unique},
b.M{"$set": upd})
} else {
// Unique has changed. Insert-Delete.
// FIXME: use transaction.
if len(secret) == 0 {
secret = record.Secret
}
if expires.IsZero() {
expires = record.Expires
}
err = a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
if err == nil {
// Delete the old record. Not much can be done with the error.
a.db.Collection("auth").DeleteOne(a.ctx, b.M{"_id": record.Unique})
}
}
return err
}
// Topic management
func (a *adapter) undeleteSubscription(sub *t.Subscription) error {
_, err := a.db.Collection("subscriptions").UpdateOne(a.ctx,
b.M{"_id": sub.Id},
b.M{
"$unset": b.M{"deletedat": ""},
"$set": b.M{
"updatedat": sub.UpdatedAt,
"createdat": sub.CreatedAt,
"modegiven": sub.ModeGiven,
"modewant": sub.ModeWant,
"delid": 0,
"readseqid": 0,
"recvseqid": 0}})
return err
}
// TopicCreate creates a topic
func (a *adapter) TopicCreate(topic *t.Topic) error {
_, err := a.db.Collection("topics").InsertOne(a.ctx, &topic)
return err
}
// TopicCreateP2P creates a p2p topic.
func (a *adapter) TopicCreateP2P(initiator, invited *t.Subscription) error {
initiator.Id = initiator.Topic + ":" + initiator.User
// Don't care if the initiator changes own subscription
replOpts := mdbopts.Replace().SetUpsert(true)
_, err := a.db.Collection("subscriptions").ReplaceOne(a.ctx, b.M{"_id": initiator.Id}, initiator, replOpts)
if err != nil {
return err
}
// If the second subscription exists, don't overwrite it. Just make sure it's not deleted.
invited.Id = invited.Topic + ":" + invited.User
_, err = a.db.Collection("subscriptions").InsertOne(a.ctx, invited)
if err != nil {
// Is this a duplicate subscription?
if !isDuplicateErr(err) {
// It's a genuine DB error
return err
}
// Undelete the second subsription if it exists: remove DeletedAt, update CreatedAt and UpdatedAt,
// update ModeGiven.
err = a.undeleteSubscription(invited)
if err != nil {
return err
}
}
topic := &t.Topic{
ObjHeader: t.ObjHeader{Id: initiator.Topic},
TouchedAt: initiator.GetTouchedAt(),
}
topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
return a.TopicCreate(topic)
}
// 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) {
var tt = new(t.Topic)
if err := a.db.Collection("topics").FindOne(a.ctx, b.M{"_id": topic}).Decode(tt); err != nil {
if err == mdb.ErrNoDocuments {
return nil, nil
}
return nil, err
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
// Topic found, get subsription count.
subCnt, err := a.subscriptionCount(topic)
if err != nil {
return nil, err
}
if int(subCnt) != tt.SubCnt {
// Update the topic with the correct subscription count.
tt.SubCnt = int(subCnt)
err = a.topicUpdate(topic, b.M{"subcnt": tt.SubCnt})
if err != nil {
return nil, err
}
}
}
tt.Public = unmarshalBsonD(tt.Public)
tt.Trusted = unmarshalBsonD(tt.Trusted)
return tt, nil
}
// TopicsForUser loads user's contact list: p2p and grp topics, except for 'me' & 'fnd' subscriptions.
// Reads and denormalizes Public & Trusted values.
func (a *adapter) TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
// Fetch all user's subscriptions.
filter := b.M{"user": uid.String()}
if !keepDeleted {
// Filter out rows with defined deletedat
filter["deletedat"] = b.M{"$exists": false}
}
limit := 0
ims := time.Time{}
if opts != nil {
if opts.Topic != "" {
filter["topic"] = opts.Topic
}
// Apply the limit only when the client does not manage the cache (or cold start).
// Otherwise have to get all subscriptions and do a manual join with users/topics.
if opts.IfModifiedSince == nil {
if opts.Limit > 0 && opts.Limit < a.maxResults {
limit = opts.Limit
} else {
limit = a.maxResults
}
} else {
ims = *opts.IfModifiedSince
}
} else {
limit = a.maxResults
}
var findOpts *mdbopts.FindOptions
if limit > 0 {
findOpts = mdbopts.Find().SetLimit(int64(limit))
}
cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
// Must close the cursor manually as we will be reusing it.
// Fetch subscriptions. Two queries are needed: users table (me & p2p) and topics table (p2p and grp).
// Prepare a list of Separate subscriptions to users vs topics
join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
topq := make([]string, 0, 16)
usrq := make([]string, 0, 16)
for cur.Next(a.ctx) {
var sub t.Subscription
if err = cur.Decode(&sub); err != nil {
break
}
tname := sub.Topic
sub.User = uid.String()
tcat := t.GetTopicCat(tname)
if tcat == t.TopicCatMe || tcat == t.TopicCatFnd {
// Skip 'me' or 'fnd' subscription. Don't skip 'sys'.
continue
} else if tcat == t.TopicCatP2P {
// P2P subscription, find the other user to get user.Public
uid1, uid2, _ := t.ParseP2P(sub.Topic)
if uid1 == uid {
usrq = append(usrq, uid2.String())
sub.SetWith(uid2.UserId())
} else {
usrq = append(usrq, uid1.String())
sub.SetWith(uid1.UserId())
}
topq = append(topq, tname)
} else if tcat == t.TopicCatGrp {
// Maybe convert channel name to topic name.
tname = t.ChnToGrp(tname)
}
// No special handling needed for 'slf', 'sys' subscriptions.
topq = append(topq, tname)
sub.Private = unmarshalBsonD(sub.Private)
join[tname] = sub
}
cur.Close(a.ctx)
if err != nil {
return nil, err
}
var subs []t.Subscription
if len(join) == 0 {
return subs, nil
}
if len(topq) > 0 {
// Fetch grp & p2p topics
filter = b.M{"_id": b.M{"$in": topq}}
if !keepDeleted {
filter["state"] = b.M{"$ne": t.StateDeleted}
}
if !ims.IsZero() {
// Use cache timestamp if provided: get newer entries only.
filter["touchedat"] = b.M{"$gt": ims}
findOpts = nil
if limit > 0 && limit < len(topq) {
// No point in fetching more than the requested limit.
findOpts = mdbopts.Find().SetSort(b.D{{"touchedat", 1}}).SetLimit(int64(limit))
}
}
cur, err = a.db.Collection("topics").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
for cur.Next(a.ctx) {
var top t.Topic
if err = cur.Decode(&top); err != nil {
break
}
sub := join[top.Id]
// Check if sub.UpdatedAt needs to be adjusted to earlier or later time.
sub.UpdatedAt = common.SelectLatestTime(sub.UpdatedAt, top.UpdatedAt)
sub.SetState(top.State)
sub.SetTouchedAt(top.TouchedAt)
sub.SetSeqId(top.SeqId)
if t.GetTopicCat(sub.Topic) == t.TopicCatGrp {
sub.SetSubCnt(top.SubCnt)
sub.SetPublic(unmarshalBsonD(top.Public))
sub.SetTrusted(unmarshalBsonD(top.Trusted))
}
// Put back the updated value of a p2p subsription, will process further below
join[top.Id] = sub
}
cur.Close(a.ctx)
if err != nil {
return nil, err
}
}
// Fetch p2p users and join to p2p tables
if len(usrq) > 0 {
filter = b.M{"_id": b.M{"$in": usrq}}
if !keepDeleted {
filter["state"] = b.M{"$ne": t.StateDeleted}
}
// Ignoring ims: we need all users to get LastSeen and UserAgent.
cur, err = a.db.Collection("users").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
for cur.Next(a.ctx) {
var usr2 t.User
if err = cur.Decode(&usr2); err != nil {
break
}
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(unmarshalBsonD(usr2.Public))
sub.SetTrusted(unmarshalBsonD(usr2.Trusted))
sub.SetDefaultAccess(usr2.Access.Auth, usr2.Access.Anon)
sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
join[joinOn] = sub
}
}
cur.Close(a.ctx)
if err != nil {
return nil, err
}
}
subs = make([]t.Subscription, 0, len(join))
for _, sub := range join {
subs = append(subs, sub)
}
return common.SelectEarliestUpdatedSubs(subs, opts, a.maxResults), nil
}
// UsersForTopic loads users' subscriptions for a given topic (not channel readers).
// Public & Trusted are loaded.
func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
tcat := t.GetTopicCat(topic)
// Fetch all subscribed users. The number of users is not large.
filter := b.M{"topic": topic}
if !keepDeleted && tcat != t.TopicCatP2P {
// Filter out rows with DeletedAt being not null.
// P2P topics must load all subscriptions otherwise it will be impossible
// to swap Public values.
filter["deletedat"] = b.M{"$exists": false}
}
limit := a.maxResults
var oneUser t.Uid
if opts != nil {
// Ignore IfModifiedSince - we must return all entries
// Those unmodified will be stripped of Public, Trusted & Private.
if !opts.User.IsZero() {
if tcat != t.TopicCatP2P {
filter["user"] = opts.User.String()
}
oneUser = opts.User
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, mdbopts.Find().SetLimit(int64(limit)))
if err != nil {
return nil, err
}
// Fetch subscriptions.
var subs []t.Subscription
join := make(map[string]t.Subscription)
usrq := make([]any, 0, 16)
for cur.Next(a.ctx) {
var sub t.Subscription
if err = cur.Decode(&sub); err != nil {
break
}
join[sub.User] = sub
usrq = append(usrq, sub.User)
}
cur.Close(a.ctx)
if err != nil {
return nil, err
}
// Fetch users by a list of subscriptions.
if len(usrq) > 0 {
subs = make([]t.Subscription, 0, len(usrq))
cur, err = a.db.Collection("users").Find(a.ctx, b.M{
"_id": b.M{"$in": usrq},
"state": b.M{"$ne": t.StateDeleted}})
if err != nil {
return nil, err
}
for cur.Next(a.ctx) {
var usr2 t.User
if err = cur.Decode(&usr2); err != nil {
break
}
if sub, ok := join[usr2.Id]; ok {
sub.ObjHeader.MergeTimes(&usr2.ObjHeader)
sub.Private = unmarshalBsonD(sub.Private)
sub.SetPublic(unmarshalBsonD(usr2.Public))
sub.SetTrusted(unmarshalBsonD(usr2.Trusted))
sub.SetLastSeenAndUA(usr2.LastSeen, usr2.UserAgent)
subs = append(subs, sub)
}
}
cur.Close(a.ctx)
if err != nil {
return nil, err
}
}
if t.GetTopicCat(topic) == t.TopicCatP2P && len(subs) > 0 {
// Swap public values & lastSeen of P2P topics as expected.
if len(subs) == 1 {
// User is deleted. Nothing we can do.
subs[0].SetPublic(nil)
subs[0].SetTrusted(nil)
subs[0].SetLastSeenAndUA(nil, "")
} else {
tmp := subs[0].GetPublic()
subs[0].SetPublic(subs[1].GetPublic())
subs[1].SetPublic(tmp)
tmp = subs[0].GetTrusted()
subs[0].SetTrusted(subs[1].GetTrusted())
subs[1].SetTrusted(tmp)
lastSeen := subs[0].GetLastSeen()
userAgent := subs[0].GetUserAgent()
subs[0].SetLastSeenAndUA(subs[1].GetLastSeen(), subs[1].GetUserAgent())
subs[1].SetLastSeenAndUA(lastSeen, userAgent)
}
// Remove deleted and unneeded subscriptions
if !keepDeleted || !oneUser.IsZero() {
var xsubs []t.Subscription
for i := range subs {
if (subs[i].DeletedAt != nil && !keepDeleted) || (!oneUser.IsZero() && subs[i].Uid() != oneUser) {
continue
}
xsubs = append(xsubs, subs[i])
}
subs = xsubs
}
}
return subs, nil
}
// topicNamesForUser reads topic names from the 'field' of 'collection' using 'filter'.
// If includeChan is true, for group topics also add the corresponding channel name.
func (a *adapter) topicNamesForUser(collection string, filter b.M, field string, includeChan bool) ([]string, error) {
cur, err := a.db.Collection(collection).Find(a.ctx, filter,
mdbopts.Find().SetProjection(b.M{field: 1}))
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var names []string
for cur.Next(a.ctx) {
var res map[string]string
if err = cur.Decode(&res); err != nil {
break
}
names = append(names, res[field])
// If the name is a group topic, also add the channel name if requested.
if includeChan {
if channel := t.GrpToChn(res[field]); channel != "" {
names = append(names, channel)
}
}
}
return names, err
}
func (a *adapter) p2pTopicsForUser(uid t.Uid) ([]string, error) {
return a.topicNamesForUser("subscriptions",
b.M{
"user": uid.String(),
"deletedat": b.M{"$exists": false},
"topic": b.M{"$regex": primitive.Regex{Pattern: "^p2p"}}},
"topic", false)
}
// 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("topics",
b.M{"owner": uid.String(), "state": b.M{"$ne": t.StateDeleted}},
"_id", false)
}
// 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("subscriptions",
b.M{
"user": uid.String(),
"deletedat": b.M{"$exists": false},
"topic": b.M{"$regex": primitive.Regex{Pattern: "^chn"}},
"modewant": b.M{"$bitsAllSet": b.A{t.ModePres}},
"modegiven": b.M{"$bitsAllSet": b.A{t.ModePres}}},
"topic", false)
}
// TopicShare creates topic subscriptions.
func (a *adapter) TopicShare(topic string, shares []*t.Subscription) error {
// Assign Ids.
for _, sub := range shares {
sub.Id = sub.Topic + ":" + sub.User
}
// Subscription could have been marked as deleted (DeletedAt != nil). If it's marked
// as deleted, unmark by clearing the DeletedAt field of the old subscription and
// updating times and ModeGiven.
for _, sub := range shares {
_, err := a.db.Collection("subscriptions").InsertOne(a.ctx, sub)
if err != nil {
if isDuplicateErr(err) {
if err = a.undeleteSubscription(sub); err != nil {
return err
}
} else {
return err
}
}
}
if topic != "" {
// Update topic's subscription count.
// The error is ignored because the subscriptions have been created already.
a.db.Collection("topics").UpdateOne(a.ctx,
b.M{"_id": topic},
b.M{"$inc": b.M{"subcnt": len(shares)}})
}
return nil
}
// TopicDelete deletes topic, subscriptions, messages.
func (a *adapter) TopicDelete(topic string, isChan, hard bool) error {
filter := b.M{}
if isChan {
// If the topic is a channel, must try to delete subscriptions under both grpXXX and chnXXX names.
filter["$or"] = b.A{
b.M{"topic": topic},
b.M{"topic": t.GrpToChn(topic)},
}
} else {
filter["topic"] = topic
}
err := a.subsDelete(a.ctx, filter, hard)
if err != nil {
return err
}
filter = b.M{"_id": topic}
if hard {
if err = a.decFileUseCounter(a.ctx, "topics", filter); err != nil {
return err
}
if err = a.MessageDeleteList(topic, nil); err != nil {
return err
}
_, err = a.db.Collection("topics").DeleteOne(a.ctx, filter)
} else {
_, err = a.db.Collection("topics").UpdateOne(a.ctx, filter, b.M{"$set": b.M{
"state": t.StateDeleted,
"stateat": t.TimeNow(),
}})
}
return err
}
// TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp.
func (a *adapter) TopicUpdateOnMessage(topic string, msg *t.Message) error {
return a.topicUpdate(topic, b.M{"seqid": msg.SeqId, "touchedat": msg.CreatedAt})
}
func (a *adapter) subscriptionCount(topic string) (int64, error) {
// Get count of non-deleted subscriptions to the topic.
return a.db.Collection("subscriptions").CountDocuments(a.ctx, b.M{
"topic": b.M{"$in": b.A{topic, t.GrpToChn(topic)}},
"deletedat": b.M{"$exists": false},
})
}
// TopicUpdateSubCnt updates subscriber count denormalized in topic.
func (a *adapter) TopicUpdateSubCnt(topic string) error {
// Get count of non-deleted subscriptions to the topic.
// UPDATE ... SET=(SELECT ...) is not supported in MongoDB, so we have to do it in two queries.
count, err := a.subscriptionCount(topic)
if err != nil {
return err
}
return a.topicUpdate(topic, b.M{"subcnt": count})
}
// TopicUpdate updates topic record.
func (a *adapter) TopicUpdate(topic string, update map[string]any) error {
if t, u := update["TouchedAt"], update["UpdatedAt"]; t == nil && u != nil {
update["TouchedAt"] = u
}
return a.topicUpdate(topic, normalizeUpdateMap(update))
}
// TopicOwnerChange updates topic's owner
func (a *adapter) TopicOwnerChange(topic string, newOwner t.Uid) error {
return a.topicUpdate(topic, map[string]any{"owner": newOwner.String()})
}
func (a *adapter) topicUpdate(topic string, update map[string]any) error {
_, err := a.db.Collection("topics").UpdateOne(a.ctx,
b.M{"_id": topic},
b.M{"$set": update})
return err
}
// Topic subscriptions
// SubscriptionGet reads a subscription of a user to a topic.
func (a *adapter) SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error) {
sub := new(t.Subscription)
filter := b.M{"_id": topic + ":" + user.String()}
if !keepDeleted {
filter["deletedat"] = b.M{"$exists": false}
}
err := a.db.Collection("subscriptions").FindOne(a.ctx, filter).Decode(sub)
if err != nil {
if err == mdb.ErrNoDocuments {
return nil, nil
}
return nil, err
}
return sub, nil
}
// SubsForUser loads all subscriptions of a given user. It does NOT load Public, Trusted or Private values,
// does not load deleted subs.
func (a *adapter) SubsForUser(user t.Uid) ([]t.Subscription, error) {
filter := b.M{"user": user.String(), "deletedat": b.M{"$exists": false}}
cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var subs []t.Subscription
for cur.Next(a.ctx) {
var ss t.Subscription
if err := cur.Decode(&ss); err != nil {
return nil, err
}
ss.Private = nil
subs = append(subs, ss)
}
return subs, cur.Err()
}
// SubsForTopic fetches all subsciptions for a topic. Does NOT load Public value and does not load channel readers.
// The difference between UsersForTopic vs SubsForTopic is that the former loads user.public+trusted,
// the latter does not.
func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) {
filter := b.M{"topic": topic}
if !keepDeleted {
filter["deletedat"] = b.M{"$exists": false}
}
limit := a.maxResults
if opts != nil {
// Ignore IfModifiedSince - we must return all entries
// Those unmodified will be stripped of Public, Trusted & Private.
if !opts.User.IsZero() {
filter["user"] = opts.User.String()
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
findOpts := new(mdbopts.FindOptions).SetLimit(int64(limit))
cur, err := a.db.Collection("subscriptions").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var subs []t.Subscription
for cur.Next(a.ctx) {
var ss t.Subscription
if err := cur.Decode(&ss); err != nil {
return nil, err
}
ss.Private = unmarshalBsonD(ss.Private)
subs = append(subs, ss)
}
return subs, cur.Err()
}
// SubsUpdate updates part of a subscription object. Pass nil for fields which don't need to be updated
func (a *adapter) SubsUpdate(topic string, user t.Uid, update map[string]any) error {
// Convert CamelCase field names to lowercase.
update = normalizeUpdateMap(update)
filter := b.M{}
if !user.IsZero() {
// Update one topic subscription
filter["_id"] = topic + ":" + user.String()
} else {
// Update all topic subscriptions
filter["topic"] = topic
}
_, err := a.db.Collection("subscriptions").UpdateOne(a.ctx, filter, b.M{"$set": update})
return err
}
// SubsDelete marks at most one subscription as deleted (soft-deleting).
func (a *adapter) SubsDelete(topic string, user t.Uid) error {
var sess mdb.Session
var err error
if sess, err = a.conn.StartSession(); err != nil {
return err
}
defer sess.EndSession(a.ctx)
if err = a.maybeStartTransaction(sess); err != nil {
return err
}
forUser := user.String()
return mdb.WithSession(a.ctx, sess, func(sc mdb.SessionContext) error {
if err := a.subsDelete(sc, b.M{"_id": topic + ":" + forUser}, false); err != nil {
return err
}
// Channel readers cannot delete messages.
if !t.IsChannel(topic) {
// Delete user's dellog entries.
if _, err := a.db.Collection("dellog").DeleteMany(sc, b.M{"topic": topic, "deletedfor": forUser}); err != nil {
return err
}
// Delete user's markings of soft-deleted messages
filter := b.M{"topic": topic, "deletedfor.user": forUser}
if _, err := a.db.Collection("messages").
UpdateMany(sc, filter, b.M{"$pull": b.M{"deletedfor": b.M{"user": forUser}}}); err != nil {
return err
}
}
if t.GetTopicCat(topic) == t.TopicCatGrp {
// Decrement topic subscription count (only one subscription is deleted).
if err := a.topicUpdate(topic, b.M{"subcnt": -1}); err != nil {
return err
}
}
// Commit changes.
return a.maybeCommitTransaction(sc, sess)
})
}
// clearUserDellog deletes all dellog entries and deletedfor markings of a given user.
func (a *adapter) clearUserDellog(sc mdb.SessionContext, forUser string) error {
topics, err := a.db.Collection("subscriptions").Distinct(sc, "topic",
b.M{"user": forUser, "deletedat": b.M{"$exists": false}})
if err != nil {
return err
}
// No need to convert channel names to group names:
// channel readers cannot delete messages.
if len(topics) > 0 {
// Delete user's dellog entries.
if _, err = a.db.Collection("dellog").DeleteMany(sc,
b.M{"topic": b.M{"$in": topics}, "deletedfor": forUser}); err != nil {
return err
}
// Delete user's markings of soft-deleted messages
filter := b.M{"topic": b.M{"$in": topics}, "deletedfor.user": forUser}
if _, err = a.db.Collection("messages").
UpdateMany(sc, filter, b.M{"$pull": b.M{"deletedfor": b.M{"user": forUser}}}); err != nil {
return err
}
}
return nil
}
// Delete/mark deleted subscriptions and decrement subcnt in topic.
func (a *adapter) subsDelete(ctx context.Context, filter b.M, hard bool) error {
// First, decrement subscription count in all affected topics.
// Doing it in two steps because MongoDB does not support an equivalent of
// 'UPDATE .. LEFT JOIN ...'.
filterWithDeletedAt := copyBsonMap(filter)
filterWithDeletedAt["deletedat"] = b.M{"$exists": false}
cur, err := a.db.Collection("subscriptions").Find(ctx, filterWithDeletedAt,
mdbopts.Find().SetProjection(b.D{{"topic", 1}, {"_id", 0}}))
if err != nil {
return err
}
defer cur.Close(ctx)
var topics []string
for cur.Next(ctx) {
var result struct {
Topic string `bson:"topic"`
}
if err = cur.Decode(&result); err != nil {
return err
}
if t.IsChannel(result.Topic) {
// Convert channel name to group name.
topics = append(topics, t.ChnToGrp(result.Topic))
}
topics = append(topics, result.Topic)
}
if err = cur.Err(); err != nil {
return err
}
if len(topics) > 0 {
// Decrement subscription count in affected topics.
a.db.Collection("topics").UpdateMany(ctx,
b.M{"_id": b.M{"$in": topics}},
b.M{"$inc": b.M{"subcnt": -1}})
}
// Now delete or mark deleted the subscriptions.
if hard {
_, err = a.db.Collection("subscriptions").DeleteMany(ctx, filter)
} else {
now := t.TimeNow()
_, err = a.db.Collection("subscriptions").UpdateMany(ctx, filterWithDeletedAt,
b.M{"$set": b.M{"updatedat": now, "deletedat": now}})
}
return err
}
// Find searches for contacts and topics given a list of tags.
func (a *adapter) Find(caller, prefPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) {
/*
// MongoDB aggregation pipeline using unionWith.
[
{ $match: { tags: { $in: ["basic:alice", "travel"] } } },
{ $unionWith: {
coll: "topics",
pipeline: [ { $match: { tags: { $in: ["basic:alice", "travel"] } } } ]
}
},
{ $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1, _source: 1 } },
{ $addFields: { matchedCount: { $sum: { $map: {
input: { $setIntersection: [ "$tags", [ "alias:aliassa", "basic:alice", "travel" ] ] },
as: "tag",
in: { $cond: { if: { $regexMatch: { input: "$$tag", regex: "^alias:"} }, then: 20, else: 1 } }
} }}}},
{ $match: { $expr: { $ne: [ { $size: { $setIntersection: [ "$tags", ["basic:alice", "travel"] ] } }, 0 ] } } },
{ $sort: { matchedCount: -1 } },
{ $limit: 20 }
]
// Alternative approach using $facet for (supposedly) better performance:
[ { $facet: {
users: [
{ $match: { tags: { $in: [ "alias:alice", "basic:alice", "travel" ] } } },
{ $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1 } }
],
topics: [
{ $lookup: {
from: "topics",
pipeline: [
{ $match: { tags: { $in: [ "alias:alice", "basic:alice", "travel" ] } } },
{ $project: { _id: 1, access: 1, createdat: 1, updatedat: 1, usebt: 1, public: 1, trusted: 1, tags: 1 } } }
],
as: "topicDocs"
}},
{ $unwind: "$topicDocs" },
{ $replaceRoot: { newRoot: "$topicDocs" } }
]
}
},
{ $project: { combined: { $concatArrays: ["$users", "$topics"] } } },
{ $unwind: "$combined" },
{ $replaceRoot: { newRoot: "$combined" } },
{ $group: { _id: "$_id", doc: { $first: "$$ROOT" } } },
{ $replaceRoot: { newRoot: "$doc" } },
{ $addFields: { matchedCount:
{ $sum: { $map: { input:
{ $setIntersection: [ "$tags", [ "alias:alice", "basic:alice", "travel" ] ] },
as: "tag",
in: {
$cond: {
if: { $regexMatch: { input: "$$tag", regex: "^alias:" } }, then: 20, else: 1 }
}
} }
} } },
{ $match: { $expr: { $ne: [
{ $size: { $setIntersection: [ "$tags", [ "alias:alice", "basic:alice", "travel" ] ] } },
0
] } } },
{ $sort: { matchedCount: -1 } },
{ $limit: 20 }
]
*/
index := make(map[string]struct{})
allReq := t.FlattenDoubleSlice(req)
var allTags []any
for _, tag := range append(allReq, opt...) {
allTags = append(allTags, tag)
index[tag] = struct{}{}
}
matchOn := b.M{"tags": b.M{"$in": allTags}}
if activeOnly {
matchOn["state"] = b.M{"$eq": t.StateOK}
}
projectFields := b.M{"_id": 1, "createdat": 1, "updatedat": 1, "usebt": 1,
"access": 1, "subcnt": 1, "public": 1, "trusted": 1, "tags": 1}
pipeline := b.A{
// Stage 1: $facet
b.M{
"$facet": b.D{
{"users", b.A{
b.M{"$match": matchOn},
b.M{"$project": projectFields},
}},
{"topics", b.A{
b.M{"$lookup": b.D{
{"from", "topics"},
{"pipeline", b.A{
b.M{"$match": matchOn},
b.M{"$project": projectFields},
}},
{"as", "topicDocs"},
}},
b.M{"$unwind": "$topicDocs"},
b.M{"$replaceRoot": b.M{"newRoot": "$topicDocs"}},
}},
},
},
// Stage 2: $project
b.M{"$project": b.M{"combined": b.M{"$concatArrays": b.A{"$users", "$topics"}}}},
// Stage 3: $unwind
b.M{"$unwind": "$combined"},
// Stage 4: $replaceRoot
b.M{"$replaceRoot": b.M{"newRoot": "$combined"}},
// Stage 5: $group
b.M{"$group": b.D{{"_id", "$_id"}, {"doc", b.M{"$first": "$$ROOT"}}}},
// Stage 6: $replaceRoot
b.M{"$replaceRoot": b.M{"newRoot": "$doc"}},
// Stage 7: $addFields
b.M{"$addFields": b.M{"matchedCount": b.M{"$sum": b.M{"$map": b.D{
{"input", b.M{"$setIntersection": b.A{"$tags", allTags}}},
{"as", "tag"},
{"in", b.D{
{"$cond", b.D{
{"if", b.M{"$regexMatch": b.D{
{"input", "$$tag"},
{"regex", "^alias:"},
},
}},
{"then", 20},
{"else", 1},
}}}}},
}}}},
}
// Ensure required tags are present.
for _, reqDisjunction := range req {
if len(reqDisjunction) == 0 {
continue
}
var reqTags []any
for _, tag := range reqDisjunction {
reqTags = append(reqTags, tag)
}
// Filter out documents where 'tags' intersection with 'reqTags' is an empty array.
pipeline = append(pipeline,
b.M{"$match": b.M{"$expr": b.M{"$ne": b.A{b.M{"$size": b.M{"$setIntersection": b.A{"$tags", reqTags}}}, 0}}}})
}
pipeline = append(pipeline,
// Stage 9: $sort
b.M{"$sort": b.D{{"matchedCount", -1}, {"subcnt", -1}}},
// Stage 10: $limit
b.M{"$limit": a.maxResults},
)
cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var subs []t.Subscription
for cur.Next(a.ctx) {
var topic t.Topic
var sub t.Subscription
if err = cur.Decode(&topic); err != nil {
break
}
if topic.UseBt {
// This is a channel, convert grp to chn name: all channel-capable
// topics should appear as channels in search results.
sub.Topic = t.GrpToChn(topic.Id)
} else {
if uid := t.ParseUid(topic.Id); !uid.IsZero() {
topic.Id = uid.UserId()
if topic.Id == caller {
// Skip the caller.
continue
}
}
sub.Topic = topic.Id
}
sub.CreatedAt = topic.CreatedAt
sub.UpdatedAt = topic.UpdatedAt
sub.SetSubCnt(topic.SubCnt)
sub.SetPublic(unmarshalBsonD(topic.Public))
sub.SetTrusted(unmarshalBsonD(topic.Trusted))
sub.SetDefaultAccess(topic.Access.Auth, topic.Access.Anon)
// Indicating that the mode is not set, not 'N'.
sub.ModeGiven = t.ModeUnset
sub.ModeWant = t.ModeUnset
sub.Private = common.FilterFoundTags(topic.Tags, index)
subs = append(subs, sub)
}
if err == nil {
err = cur.Err()
}
return subs, err
}
// FindOne returns the first topic or user which matches the given tag.
func (a *adapter) FindOne(tag string) (string, error) {
// Part of the pipeline identical for users and topics collections.
commonPipe := b.A{b.M{"$match": b.M{"tags": tag}}, b.M{"$project": b.M{"_id": 1}}}
// Must create a copy of commonPipe so the original commonPipe can be used unmodified in $unionWith.
pipeline := append(slices.Clone(commonPipe),
b.M{"$unionWith": b.M{"coll": "topics", "pipeline": commonPipe}},
b.M{"$limit": 1})
cur, err := a.db.Collection("users").Aggregate(a.ctx, pipeline)
if err != nil {
return "", err
}
defer cur.Close(a.ctx)
var found string
if cur.Next(a.ctx) {
entry := map[string]any{}
if err = cur.Decode(&entry); err != nil {
return "", err
}
if id, ok := entry["_id"].(string); ok {
if user := t.ParseUid(id); !user.IsZero() {
found = user.UserId()
} else {
found = id
}
}
}
return found, cur.Err()
}
// Messages
// MessageSave saves message to database
func (a *adapter) MessageSave(msg *t.Message) error {
_, err := a.db.Collection("messages").InsertOne(a.ctx, msg)
return err
}
// MessageGetAll returns messages matching the query.
func (a *adapter) MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
var limit = a.maxMessageResults
var lower, upper int
requester := forUser.String()
if opts != nil {
if opts.Since > 0 {
lower = opts.Since
}
if opts.Before > 0 {
upper = opts.Before
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
filter := b.M{
"topic": topic,
"delid": b.M{"$exists": false},
"deletedfor.user": b.M{"$ne": requester},
}
if upper == 0 {
filter["seqid"] = b.M{"$gte": lower}
} else {
filter["seqid"] = b.M{"$gte": lower, "$lt": upper}
}
findOpts := mdbopts.Find().SetSort(b.D{{"topic", -1}, {"seqid", -1}})
findOpts.SetLimit(int64(limit))
cur, err := a.db.Collection("messages").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var msgs []t.Message
for cur.Next(a.ctx) {
var msg t.Message
if err = cur.Decode(&msg); err != nil {
return nil, err
}
msg.Content = unmarshalBsonD(msg.Content)
msgs = append(msgs, msg)
}
return msgs, nil
}
func (a *adapter) messagesHardDelete(topic string) error {
var err error
// TODO: handle file uploads
filter := b.M{"topic": topic}
if _, err = a.db.Collection("dellog").DeleteMany(a.ctx, filter); err != nil {
return err
}
if err = a.decFileUseCounter(a.ctx, "messages", filter); err != nil {
return err
}
if _, err = a.db.Collection("messages").DeleteMany(a.ctx, filter); err != nil {
return err
}
return err
}
// rangeToFilter is Mongo's equivalent of common.RangeToSql.
func rangeToFilter(delRanges []t.Range, filter b.M) b.M {
if len(delRanges) > 1 || delRanges[0].Hi == 0 {
rangeFilter := b.A{}
for _, rng := range delRanges {
if rng.Hi == 0 {
rangeFilter = append(rangeFilter, b.M{"seqid": rng.Low})
} else {
rangeFilter = append(rangeFilter, b.M{"seqid": b.M{"$gte": rng.Low, "$lt": rng.Hi}})
}
}
filter["$or"] = rangeFilter
} else {
filter["seqid"] = b.M{"$gte": delRanges[0].Low, "$lt": delRanges[0].Hi}
}
return filter
}
// MessageDeleteList marks messages as deleted.
// Soft- or Hard- is defined by forUser value: forUSer.IsZero == true is hard.
func (a *adapter) MessageDeleteList(topic string, toDel *t.DelMessage) error {
var err error
if toDel == nil {
// No filter: delete all messages.
return a.messagesHardDelete(topic)
}
// Only some messages are being deleted
delRanges := toDel.SeqIdRanges
filter := b.M{
"topic": topic,
// Skip already hard-deleted messages.
"delid": b.M{"$exists": false},
}
// Mongo's equivalent of common.RangeToSql
rangeToFilter(delRanges, filter)
if toDel.DeletedFor == "" {
// Hard-deleting messages requires updates to the messages table.
// We are asked to delete messages no older than newerThan.
if newerThan := toDel.GetNewerThan(); newerThan != nil {
filter["createdat"] = b.M{"$gt": newerThan}
}
pipeline := b.A{
b.M{"$match": filter},
b.M{"$project": b.M{"seqid": 1}},
}
// Find the actual IDs still present in the database.
cur, err := a.db.Collection("messages").Aggregate(a.ctx, pipeline)
if err != nil {
return err
}
defer cur.Close(a.ctx)
var seqIDs []int
for cur.Next(a.ctx) {
var result struct {
SeqID int `bson:"seqid"`
}
if err = cur.Decode(&result); err != nil {
return err
}
seqIDs = append(seqIDs, result.SeqID)
}
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.
filter = b.M{
"topic": topic,
}
rangeToFilter(delRanges, filter)
if err = a.decFileUseCounter(a.ctx, "messages", filter); err != nil {
return err
}
// Hard-delete individual messages. Message is not deleted but all fields with content
// are replaced with nulls.
_, err = a.db.Collection("messages").UpdateMany(a.ctx, filter, b.M{"$set": b.M{
"deletedat": t.TimeNow(),
"delid": toDel.DelId,
"from": "",
"head": nil,
"content": nil,
"attachments": nil}})
} else {
// Soft-deleting: adding DelId to DeletedFor
// Skip messages already soft-deleted for the current user
filter["deletedfor.user"] = b.M{"$ne": toDel.DeletedFor}
_, err = a.db.Collection("messages").UpdateMany(a.ctx, filter,
b.M{"$addToSet": b.M{
"deletedfor": &t.SoftDelete{
User: toDel.DeletedFor,
DelId: toDel.DelId,
}}})
}
// Make log entries. Needed for both hard- and soft-deleting.
_, err = a.db.Collection("dellog").InsertOne(a.ctx, toDel)
return err
}
// MessageGetDeleted returns a list of deleted message Ids.
func (a *adapter) MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
var limit = a.maxResults
var lower, upper int
if opts != nil {
if opts.Since > 0 {
lower = opts.Since
}
if opts.Before > 0 {
upper = opts.Before
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
filter := b.M{
"topic": topic,
"$or": b.A{
b.M{"deletedfor": forUser.String()},
b.M{"deletedfor": ""},
}}
if upper == 0 {
filter["delid"] = b.M{"$gte": lower}
} else {
filter["delid"] = b.M{"$gte": lower, "$lt": upper}
}
findOpts := mdbopts.Find().
SetSort(b.D{{"topic", 1}, {"delid", 1}}).
SetLimit(int64(limit))
cur, err := a.db.Collection("dellog").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var dmsgs []t.DelMessage
if err = cur.All(a.ctx, &dmsgs); err != nil {
return nil, err
}
return dmsgs, nil
}
// Devices (for push notifications).
// DeviceUpsert creates or updates a device record.
func (a *adapter) DeviceUpsert(uid t.Uid, dev *t.DeviceDef) error {
userId := uid.String()
var user t.User
err := a.db.Collection("users").FindOne(a.ctx, b.M{
"_id": userId,
"devices.deviceid": dev.DeviceId}).Decode(&user)
if err == nil && user.Id != "" { // current user owns this device
// ArrayFilter used to avoid adding another (duplicate) device object. Update that device data
updOpts := mdbopts.Update().SetArrayFilters(mdbopts.ArrayFilters{
Filters: []any{b.M{"dev.deviceid": dev.DeviceId}}})
_, err = a.db.Collection("users").UpdateOne(a.ctx,
b.M{"_id": userId},
b.M{"$set": b.M{
"devices.$[dev].platform": dev.Platform,
"devices.$[dev].lastseen": dev.LastSeen,
"devices.$[dev].lang": dev.Lang}},
updOpts)
return err
} else if err == mdb.ErrNoDocuments { // device is free or owned by other user
err = a.deviceInsert(userId, dev)
if isDuplicateErr(err) {
// Other user owns this device.
// We need to delete this device from that user and then insert again
if _, err = a.db.Collection("users").UpdateOne(a.ctx,
b.M{"devices.deviceid": dev.DeviceId},
b.M{"$pull": b.M{"devices": b.M{"deviceid": dev.DeviceId}}}); err != nil {
return err
}
return a.deviceInsert(userId, dev)
}
if err != nil {
return err
}
return nil
}
return err
}
// deviceInsert adds device object to user.devices array
func (a *adapter) deviceInsert(userId string, dev *t.DeviceDef) error {
filter := b.M{"_id": userId}
_, err := a.db.Collection("users").UpdateOne(a.ctx, filter,
b.M{"$push": b.M{"devices": dev}})
if err != nil && strings.Contains(err.Error(), "must be an array") {
// field 'devices' is not array. Make it array with 'dev' as its first element
_, err = a.db.Collection("users").UpdateOne(a.ctx, filter,
b.M{"$set": b.M{"devices": []any{dev}}})
}
return err
}
// DeviceGetAll returns all devices for a given set of users.
func (a *adapter) DeviceGetAll(uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
ids := make([]any, len(uids))
for i, id := range uids {
ids[i] = id.String()
}
filter := b.M{"_id": b.M{"$in": ids}}
findOpts := mdbopts.Find().SetProjection(b.M{"_id": 1, "devices": 1})
cur, err := a.db.Collection("users").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, 0, err
}
defer cur.Close(a.ctx)
result := make(map[t.Uid][]t.DeviceDef)
count := 0
var uid t.Uid
for cur.Next(a.ctx) {
var row struct {
Id string `bson:"_id"`
Devices []t.DeviceDef
}
if err = cur.Decode(&row); err != nil {
return nil, 0, err
}
if len(row.Devices) > 0 {
if err := uid.UnmarshalText([]byte(row.Id)); err != nil {
continue
}
result[uid] = row.Devices
count++
}
}
return result, count, cur.Err()
}
// DeviceDelete deletes a device record (push token).
func (a *adapter) DeviceDelete(uid t.Uid, deviceID string) error {
var err error
filter := b.M{"_id": uid.String()}
update := b.M{}
if deviceID == "" {
update["$set"] = b.M{"devices": []any{}}
} else {
update["$pull"] = b.M{"devices": b.M{"deviceid": deviceID}}
}
_, err = a.db.Collection("users").UpdateOne(a.ctx, filter, update)
return err
}
// File upload records. The files are stored outside of the database.
// FileStartUpload initializes a file upload
func (a *adapter) FileStartUpload(fd *t.FileDef) error {
_, err := a.db.Collection("fileuploads").InsertOne(a.ctx, fd)
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) {
now := t.TimeNow()
if success {
// Mark upload as completed.
if _, err := a.db.Collection("fileuploads").UpdateOne(a.ctx,
b.M{"_id": fd.Id},
b.M{"$set": b.M{
"updatedat": now,
"status": t.UploadCompleted,
"size": size,
"etag": fd.ETag,
"location": fd.Location,
}}); err != nil {
return nil, err
}
fd.Status = t.UploadCompleted
fd.Size = size
} else {
// Remove record: it's now useless.
if _, err := a.db.Collection("fileuploads").DeleteOne(a.ctx, b.M{"_id": fd.Id}); err != nil {
return nil, err
}
fd.Status = t.UploadFailed
fd.Size = 0
}
fd.UpdatedAt = now
return fd, nil
}
// FileGet fetches a record of a specific file
func (a *adapter) FileGet(fid string) (*t.FileDef, error) {
var fd t.FileDef
err := a.db.Collection("fileuploads").FindOne(a.ctx, b.M{"_id": fid}).Decode(&fd)
if err != nil {
if err == mdb.ErrNoDocuments {
return nil, nil
}
return nil, err
}
return &fd, nil
}
// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes
// unused records with UpdatedAt before olderThan.
// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.
func (a *adapter) FileDeleteUnused(olderThan time.Time, limit int) ([]string, error) {
findOpts := mdbopts.Find()
filter := b.M{"$or": b.A{
b.M{"usecount": 0},
b.M{"usecount": b.M{"$exists": false}}}}
if !olderThan.IsZero() {
filter["updatedat"] = b.M{"$lt": olderThan}
}
if limit > 0 {
findOpts.SetLimit(int64(limit))
}
findOpts.SetProjection(b.M{"location": 1, "_id": 0})
cur, err := a.db.Collection("fileuploads").Find(a.ctx, filter, findOpts)
if err != nil {
return nil, err
}
defer cur.Close(a.ctx)
var locations []string
for cur.Next(a.ctx) {
var result map[string]string
if err := cur.Decode(&result); err != nil {
return nil, err
}
locations = append(locations, result["location"])
}
_, err = a.db.Collection("fileuploads").DeleteMany(a.ctx, filter)
return locations, err
}
// Given a filter query against 'messages' collection, decrement corresponding use counter in 'fileuploads' table.
func (a *adapter) decFileUseCounter(ctx context.Context, collection string, msgFilter b.M) error {
// Copy msgFilter
filter := b.M{}
for k, v := range msgFilter {
filter[k] = v
}
filter["attachments"] = b.M{"$exists": true}
fileIds, err := a.db.Collection(collection).Distinct(ctx, "attachments", filter)
if err != nil {
return err
}
if len(fileIds) > 0 {
_, err = a.db.Collection("fileuploads").UpdateMany(ctx,
b.M{"_id": b.M{"$in": fileIds}},
b.M{"$inc": b.M{"usecount": -1}})
}
return err
}
// 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 == "" && userId.IsZero() && msgId.IsZero()) {
return t.ErrMalformed
}
now := t.TimeNow()
var err error
if msgId.IsZero() {
// Only one link per user or topic is permitted.
fids = fids[0:1]
// Topics and users and mutable. Must unlink the previous attachments first.
var table string
var linkId string
if topic != "" {
table = "topics"
linkId = topic
} else {
table = "users"
linkId = userId.String()
}
// Find the old attachment.
var attachments map[string][]string
findOpts := mdbopts.FindOne().SetProjection(b.M{"attachments": 1, "_id": 0})
err = a.db.Collection(table).FindOne(a.ctx, b.M{"_id": linkId}, findOpts).Decode(&attachments)
if err != nil {
return err
}
if len(attachments["attachments"]) > 0 {
// Decrement the use count of old attachment.
if _, err = a.db.Collection("fileuploads").UpdateOne(a.ctx,
b.M{"_id": attachments["attachments"][0]},
b.M{
"$set": b.M{"updatedat": now},
"$inc": b.M{"usecount": -1},
},
); err != nil {
return err
}
}
_, err = a.db.Collection(table).UpdateOne(a.ctx,
b.M{"_id": linkId},
b.M{"$set": b.M{"updatedat": now, "attachments": fids}})
if err != nil {
return err
}
} else {
_, err = a.db.Collection("messages").UpdateOne(a.ctx,
b.M{"_id": msgId.String()},
b.M{"$set": b.M{"updatedat": now, "attachments": fids}})
if err != nil {
return err
}
}
ids := make([]any, len(fids))
for i, id := range fids {
ids[i] = id
}
_, err = a.db.Collection("fileuploads").UpdateMany(a.ctx,
b.M{"_id": b.M{"$in": ids}},
b.M{
"$set": b.M{"updatedat": now},
"$inc": b.M{"usecount": 1},
},
)
return err
}
// PCacheGet reads a persistet cache entry.
func (a *adapter) PCacheGet(key string) (string, error) {
var value map[string]string
findOpts := mdbopts.FindOneOptions{Projection: b.M{"value": 1, "_id": 0}}
if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": key}, &findOpts).Decode(&value); err != nil {
if err == mdb.ErrNoDocuments {
err = t.ErrNotFound
}
return "", err
}
return value["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 $match query.
return t.ErrMalformed
}
collection := a.db.Collection("kvmeta")
doc := b.M{
"value": value,
}
if failOnDuplicate {
doc["_id"] = key
doc["createdat"] = t.TimeNow()
_, err := collection.InsertOne(a.ctx, doc)
if mdb.IsDuplicateKeyError(err) {
err = t.ErrDuplicate
}
return err
}
res := collection.FindOneAndUpdate(a.ctx, b.M{"_id": key}, b.M{"$set": doc},
mdbopts.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(mdbopts.After))
return res.Err()
}
// PCacheDelete deletes one persistent cache entry.
func (a *adapter) PCacheDelete(key string) error {
_, err := a.db.Collection("kvmeta").DeleteOne(a.ctx, b.M{"_id": 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
}
_, err := a.db.Collection("kvmeta").DeleteMany(a.ctx, b.M{"createdat": b.M{"$lt": olderThan},
"_id": primitive.Regex{Pattern: "^" + keyPrefix}})
return err
}
// GetTestDB returns a currently open database connection.
func (a *adapter) GetTestDB() any {
return a.db
}
func (a *adapter) isDbInitialized() bool {
var result map[string]int
findOpts := mdbopts.FindOneOptions{Projection: b.M{"value": 1, "_id": 0}}
if err := a.db.Collection("kvmeta").FindOne(a.ctx, b.M{"_id": "version"}, &findOpts).Decode(&result); err != nil {
return false
}
return true
}
// GetTestAdapter returns an adapter object. Useful for running tests.
func GetTestAdapter() *adapter {
return &adapter{}
}
func init() {
store.RegisterAdapter(&adapter{})
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func union(userTags, addTags []string) []string {
for _, tag := range addTags {
if !contains(userTags, tag) {
userTags = append(userTags, tag)
}
}
return userTags
}
func diff(userTags, removeTags []string) []string {
var result []string
for _, tag := range userTags {
if !contains(removeTags, tag) {
result = append(result, tag)
}
}
return result
}
// normalizeUpdateMap turns keys that hardcoded as CamelCase into lowercase (MongoDB uses lowercase by default)
func normalizeUpdateMap(update map[string]any) map[string]any {
result := make(map[string]any, len(update))
for key, value := range update {
result[strings.ToLower(key)] = value
}
return result
}
// Recursive unmarshalling of bson.D type.
// Mongo drivers unmarshalling into 'any' creates bson.D object for maps and bson.A object for slices.
// We need to manually unmarshal them into correct types: map[string]any and []any respectively.
func unmarshalBsonD(bsonObj any) any {
if obj, ok := bsonObj.(b.D); ok && len(obj) != 0 {
result := make(map[string]any)
for key, val := range obj.Map() {
result[key] = unmarshalBsonD(val)
}
return result
} else if obj, ok := bsonObj.(primitive.Binary); ok {
// primitive.Binary is a struct type with Subtype and Data fields. We need only Data ([]byte)
return obj.Data
} else if obj, ok := bsonObj.(b.A); ok {
// in case of array of bson.D objects
var result []any
for _, elem := range obj {
result = append(result, unmarshalBsonD(elem))
}
return result
}
// Just return value as is
return bsonObj
}
func copyBsonMap(mp b.M) b.M {
result := b.M{}
for k, v := range mp {
result[k] = v
}
return result
}
func isDuplicateErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "duplicate key error")
}