902 lines
28 KiB
Go
902 lines
28 KiB
Go
/******************************************************************************
|
|
*
|
|
* Description :
|
|
*
|
|
* Main hub for processing events such as creating/tearing down topics,
|
|
* routing messages between topics.
|
|
*
|
|
*****************************************************************************/
|
|
|
|
package main
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tinode/chat/server/auth"
|
|
"github.com/tinode/chat/server/logs"
|
|
immetrics "github.com/tinode/chat/server/metrics"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
)
|
|
|
|
// RequestLatencyDistribution is an array of request latency distribution bounds (in milliseconds).
|
|
// "var" because Go does not support array constants.
|
|
var requestLatencyDistribution = []float64{1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130,
|
|
160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000}
|
|
|
|
// OutgoingMessageSizeDistribution is an array of outgoing message size distribution bounds (in bytes).
|
|
var outgoingMessageSizeDistribution = []float64{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 16384,
|
|
65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}
|
|
|
|
// Request to hub to remove the topic
|
|
type topicUnreg struct {
|
|
// Tenant which owns the topic.
|
|
tenantID types.TenantID
|
|
// Original request, could be nil.
|
|
pkt *ClientComMessage
|
|
// Session making the request, could be nil.
|
|
sess *Session
|
|
// Routable name of the topic to drop. Duplicated here because pkt could be nil.
|
|
rcptTo string
|
|
// UID of the user being deleted. Duplicated here because pkt could be nil.
|
|
forUser types.Uid
|
|
// Unregister then delete the topic.
|
|
del bool
|
|
// Channel for reporting operation completion when deleting topics for a user.
|
|
done chan<- bool
|
|
}
|
|
|
|
type userStatusReq struct {
|
|
// Tenant which owns the user.
|
|
tenantID types.TenantID
|
|
// UID of the user being affected.
|
|
forUser types.Uid
|
|
// New topic state value. Only types.StateSuspended is supported at this time.
|
|
state types.ObjState
|
|
}
|
|
|
|
// Hub is the core structure which holds topics.
|
|
type Hub struct {
|
|
|
|
// Topics must be indexed by name
|
|
topics *sync.Map
|
|
|
|
// Current number of loaded topics
|
|
numTopics int
|
|
|
|
// Channel for routing client-side messages, buffered at 4096
|
|
routeCli chan *ClientComMessage
|
|
|
|
// Process get.info requests for topic not subscribed to, buffered 128.
|
|
meta chan *ClientComMessage
|
|
|
|
// Channel for routing server-generated messages, buffered at 4096
|
|
routeSrv chan *ServerComMessage
|
|
|
|
// subscribe session to topic, possibly creating a new topic, buffered at 256
|
|
join chan *ClientComMessage
|
|
|
|
// Remove topic from hub, possibly deleting it afterwards, buffered at 32
|
|
unreg chan *topicUnreg
|
|
|
|
// Channel for suspending/resuming users, buffered 128.
|
|
userStatus chan *userStatusReq
|
|
|
|
// Cluster request to rehash topics, unbuffered
|
|
rehash chan bool
|
|
|
|
// Request to shutdown, unbuffered
|
|
shutdown chan chan<- bool
|
|
}
|
|
|
|
func (h *Hub) topicGet(key types.TopicKey) *Topic {
|
|
if !key.IsValid() {
|
|
return nil
|
|
}
|
|
if t, ok := h.topics.Load(key); ok {
|
|
return t.(*Topic)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *Hub) topicPut(key types.TopicKey, t *Topic) {
|
|
if !key.IsValid() || t == nil || t.tenantID != key.TenantID || t.name != key.Topic {
|
|
panic("invalid tenant topic key")
|
|
}
|
|
h.numTopics++
|
|
h.topics.Store(key, t)
|
|
}
|
|
|
|
func (h *Hub) topicDel(key types.TopicKey) {
|
|
if !key.IsValid() {
|
|
return
|
|
}
|
|
h.numTopics--
|
|
h.topics.Delete(key)
|
|
}
|
|
|
|
func newHub() *Hub {
|
|
h := &Hub{
|
|
topics: &sync.Map{},
|
|
// TODO: verify if these channels have to be buffered.
|
|
routeCli: make(chan *ClientComMessage, 4096),
|
|
routeSrv: make(chan *ServerComMessage, 4096),
|
|
join: make(chan *ClientComMessage, 256),
|
|
unreg: make(chan *topicUnreg, 256),
|
|
rehash: make(chan bool),
|
|
meta: make(chan *ClientComMessage, 128),
|
|
userStatus: make(chan *userStatusReq, 128),
|
|
shutdown: make(chan chan<- bool),
|
|
}
|
|
|
|
statsRegisterInt("LiveTopics")
|
|
statsRegisterInt("TotalTopics")
|
|
|
|
statsRegisterInt("IncomingMessagesWebsockTotal")
|
|
statsRegisterInt("OutgoingMessagesWebsockTotal")
|
|
|
|
statsRegisterInt("IncomingMessagesLongpollTotal")
|
|
statsRegisterInt("OutgoingMessagesLongpollTotal")
|
|
|
|
statsRegisterInt("IncomingMessagesGrpcTotal")
|
|
statsRegisterInt("OutgoingMessagesGrpcTotal")
|
|
|
|
statsRegisterInt("FileDownloadsTotal")
|
|
statsRegisterInt("FileUploadsTotal")
|
|
|
|
statsRegisterInt("CtrlCodesTotal2xx")
|
|
statsRegisterInt("CtrlCodesTotal3xx")
|
|
statsRegisterInt("CtrlCodesTotal4xx")
|
|
statsRegisterInt("CtrlCodesTotal5xx")
|
|
|
|
statsRegisterHistogram("RequestLatency", requestLatencyDistribution)
|
|
statsRegisterHistogram("OutgoingMessageSize", outgoingMessageSizeDistribution)
|
|
|
|
go h.run()
|
|
|
|
return h
|
|
}
|
|
|
|
func (h *Hub) run() {
|
|
for {
|
|
select {
|
|
case join := <-h.join:
|
|
if !join.TenantID.IsValid() {
|
|
logs.Warn.Println("hub.join: missing tenant context", join.RcptTo)
|
|
if join.sess != nil {
|
|
join.sess.queueOut(ErrMalformedReply(join, join.Timestamp))
|
|
}
|
|
continue
|
|
}
|
|
// Handle a subscription request:
|
|
// 1. Init topic
|
|
// 1.1 If a new topic is requested, create it
|
|
// 1.2 If a new subscription to an existing topic is requested:
|
|
// 1.2.1 check if topic is already loaded
|
|
// 1.2.2 if not, load it
|
|
// 1.2.3 if it cannot be loaded (not found), fail
|
|
// 2. Check access rights and reject, if appropriate
|
|
// 3. Attach session to the topic
|
|
// Is the topic already loaded?
|
|
key := types.TopicKey{TenantID: join.TenantID, Topic: join.RcptTo}
|
|
t := h.topicGet(key)
|
|
if t == nil {
|
|
// Topic does not exist or not loaded.
|
|
t = &Topic{
|
|
tenantID: join.TenantID,
|
|
name: join.RcptTo,
|
|
xoriginal: join.Original,
|
|
// Indicates a proxy topic.
|
|
isProxy: globals.cluster.isRemoteTopic(key),
|
|
sessions: make(map[*Session]perSessionData),
|
|
clientMsg: make(chan *ClientComMessage, 192),
|
|
serverMsg: make(chan *ServerComMessage, 64),
|
|
reg: make(chan *ClientComMessage, 256),
|
|
unreg: make(chan *ClientComMessage, 256),
|
|
meta: make(chan *ClientComMessage, 64),
|
|
perUser: make(map[types.Uid]perUserData),
|
|
exit: make(chan *shutDown, 1),
|
|
}
|
|
if globals.cluster != nil {
|
|
if t.isProxy {
|
|
t.proxy = make(chan *ClusterResp, 128)
|
|
t.masterNode = globals.cluster.ring.Get(key.RoutingKey())
|
|
} else {
|
|
// It's a master topic. Make a channel for handling
|
|
// direct messages from the proxy.
|
|
t.master = make(chan *ClusterSessUpdate, 8)
|
|
}
|
|
}
|
|
// Topic is created in suspended state because it's not yet configured.
|
|
t.markPaused(true)
|
|
// Save topic now to prevent race condition.
|
|
h.topicPut(key, t)
|
|
|
|
// Configure the topic.
|
|
go topicInit(t, join, h)
|
|
} else {
|
|
// Topic found.
|
|
if t.isInactive() {
|
|
// Topic is either not ready or being deleted.
|
|
if join.sess.inflightReqs != nil {
|
|
join.sess.inflightReqs.Done()
|
|
}
|
|
join.sess.queueOut(ErrLockedReply(join, join.Timestamp))
|
|
continue
|
|
}
|
|
// Topic will check access rights and send appropriate {ctrl}
|
|
select {
|
|
case t.reg <- join:
|
|
default:
|
|
if join.sess.inflightReqs != nil {
|
|
join.sess.inflightReqs.Done()
|
|
}
|
|
join.sess.queueOut(ErrServiceUnavailableReply(join, join.Timestamp))
|
|
logs.Err.Println("hub.join loop: topic's reg queue full", join.RcptTo, join.sess.sid,
|
|
" - total queue len:", len(t.reg))
|
|
}
|
|
}
|
|
|
|
case msg := <-h.routeCli:
|
|
// This is a message from a session not subscribed to topic
|
|
// Route incoming message to topic if topic permits such routing.
|
|
key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
|
|
if dst := h.topicGet(key); dst != nil {
|
|
// Everything is OK, sending packet to known topic
|
|
if dst.clientMsg != nil {
|
|
select {
|
|
case dst.clientMsg <- msg:
|
|
immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), true)
|
|
default:
|
|
immetrics.ObserveInternalQueueEnqueue("topic_client", len(dst.clientMsg), cap(dst.clientMsg), false)
|
|
if msg.sess != nil {
|
|
msg.sess.queueOut(ErrServiceUnavailableReply(msg, types.TimeNow()))
|
|
}
|
|
logs.Err.Printf("hub: topic's broadcast queue is full topic=%s tenant=%d len=%d cap=%d",
|
|
dst.name, dst.tenantID, len(dst.clientMsg), cap(dst.clientMsg))
|
|
}
|
|
} else {
|
|
logs.Warn.Println("hub: invalid topic category for broadcast", dst.name)
|
|
}
|
|
} else if msg.Note == nil {
|
|
// Topic is unknown or offline.
|
|
// Note is silently ignored. Other client messages cannot be accepted here
|
|
// because they are not routed to a live topic and therefore cannot reserve a
|
|
// message seq or be persisted.
|
|
|
|
// TODO(gene): validate topic name, discarding invalid topics.
|
|
|
|
logs.Info.Printf("Hub. Topic[%s] is unknown or offline", msg.RcptTo)
|
|
|
|
if msg.sess != nil {
|
|
msg.sess.queueOut(ErrAttachFirst(msg, types.TimeNow()))
|
|
}
|
|
}
|
|
case msg := <-h.routeSrv:
|
|
// This is a server message from a connection not subscribed to topic
|
|
// Route incoming message to topic if topic permits such routing.
|
|
key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
|
|
if dst := h.topicGet(key); dst != nil {
|
|
// Everything is OK, sending packet to known topic
|
|
select {
|
|
case dst.serverMsg <- msg:
|
|
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), true)
|
|
default:
|
|
immetrics.ObserveInternalQueueEnqueue("topic_server", len(dst.serverMsg), cap(dst.serverMsg), false)
|
|
logs.Err.Println("hub: topic's broadcast queue is full", dst.name)
|
|
}
|
|
} else if (strings.HasPrefix(msg.RcptTo, "usr") || strings.HasPrefix(msg.RcptTo, "grp")) &&
|
|
globals.cluster.isRemoteTopic(key) {
|
|
// It is a remote topic.
|
|
if err := globals.cluster.routeToTopicIntraCluster(msg.RcptTo, msg, msg.sess); err != nil {
|
|
logs.Warn.Printf("hub: routing to '%s' failed", msg.RcptTo)
|
|
}
|
|
}
|
|
case msg := <-h.meta:
|
|
// Metadata read or update from a user who is not attached to the topic.
|
|
if msg.Get != nil {
|
|
if msg.MetaWhat == constMsgMetaDesc {
|
|
go replyOfflineTopicGetDesc(msg.sess, msg)
|
|
} else {
|
|
go replyOfflineTopicGetSub(msg.sess, msg)
|
|
}
|
|
} else if msg.Set != nil {
|
|
go replyOfflineTopicSetSub(msg.sess, msg)
|
|
}
|
|
|
|
case status := <-h.userStatus:
|
|
// Suspend/activate user's topics.
|
|
go h.topicsStateForUser(status.tenantID, status.forUser, status.state == types.StateSuspended)
|
|
|
|
case unreg := <-h.unreg:
|
|
reason := StopNone
|
|
if unreg.del {
|
|
reason = StopDeleted
|
|
}
|
|
if unreg.forUser.IsZero() {
|
|
// The topic is being garbage collected or deleted.
|
|
if err := h.topicUnreg(unreg.tenantID, unreg.sess, unreg.rcptTo, unreg.pkt, reason); err != nil {
|
|
logs.Err.Println("hub.topicUnreg failed:", err)
|
|
}
|
|
} else {
|
|
// User is being deleted.
|
|
go h.stopTopicsForUser(unreg.tenantID, unreg.forUser, reason, unreg.done)
|
|
}
|
|
|
|
case <-h.rehash:
|
|
// Cluster rehashing. Some previously local topics became remote,
|
|
// and the other way round.
|
|
// Such topics must be shut down at this node.
|
|
h.topics.Range(func(_, t any) bool {
|
|
topic := t.(*Topic)
|
|
// Handle two cases:
|
|
// 1. Master topic has moved out to another node.
|
|
// 2. Proxy topic is running on a new master node
|
|
// (i.e. the master topic has moved to this node).
|
|
key := types.TopicKey{TenantID: topic.tenantID, Topic: topic.name}
|
|
if topic.isProxy != globals.cluster.isRemoteTopic(key) {
|
|
h.topicUnreg(topic.tenantID, nil, topic.name, nil, StopRehashing)
|
|
}
|
|
return true
|
|
})
|
|
|
|
case hubdone := <-h.shutdown:
|
|
// start cleanup process
|
|
topicsdone := make(chan bool)
|
|
topicCount := 0
|
|
h.topics.Range(func(_, topic any) bool {
|
|
topic.(*Topic).exit <- &shutDown{done: topicsdone}
|
|
topicCount++
|
|
return true
|
|
})
|
|
|
|
for range topicCount {
|
|
<-topicsdone
|
|
}
|
|
|
|
logs.Info.Printf("Hub shutdown completed with %d topics", topicCount)
|
|
|
|
// let the main goroutine know we are done with the cleanup
|
|
hubdone <- true
|
|
|
|
return
|
|
|
|
case <-time.After(idleSessionTimeout):
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update state of all topics associated with the given user:
|
|
// * all p2p topics with the given user
|
|
// * group topics where the given user is the owner.
|
|
// 'me' and fnd' are ignored here because they are direcly tied to the user object.
|
|
func (h *Hub) topicsStateForUser(tenantID types.TenantID, uid types.Uid, suspended bool) {
|
|
h.topics.Range(func(name any, t any) bool {
|
|
topic := t.(*Topic)
|
|
if topic.tenantID != tenantID {
|
|
return true
|
|
}
|
|
if topic.cat == types.TopicCatMe || topic.cat == types.TopicCatFnd {
|
|
return true
|
|
}
|
|
|
|
if _, isMember := topic.perUser[uid]; (topic.cat == types.TopicCatP2P && isMember) || topic.owner == uid {
|
|
topic.markReadOnly(suspended)
|
|
|
|
// Don't send "off" notification on suspension. They will be sent when the user is evicted.
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// topicUnreg deletes or unregisters the topic:
|
|
//
|
|
// Cases:
|
|
// 1. Topic being deleted
|
|
// 1.1 Topic is online
|
|
// 1.1.1 If the requester is the owner or if it's the last sub in a p2p topic (p2p may be sent internally when the last user unsubscribes):
|
|
// 1.1.1.1 Tell topic to stop accepting requests.
|
|
// 1.1.1.2 Hub deletes the topic from database
|
|
// 1.1.1.3 Hub unregisters the topic
|
|
// 1.1.1.4 Hub informs the origin of success or failure
|
|
// 1.1.1.5 Hub forwards request to topic
|
|
// 1.1.1.6 Topic evicts all sessions
|
|
// 1.1.1.7 Topic exits the run() loop
|
|
// 1.1.2 If the requester is not the owner
|
|
// 1.1.2.1 Send it to topic to be treated like {leave unsub=true}
|
|
//
|
|
// 1.2 Topic is offline
|
|
// 1.2.1 If requester is the owner
|
|
// 1.2.1.1 Hub deletes topic from database
|
|
// 1.2.2 If not the owner
|
|
// 1.2.2.1 Delete subscription from DB
|
|
// 1.2.3 Hub informs the origin of success or failure
|
|
// 1.2.4 Send notification to subscribers that the topic was deleted
|
|
|
|
// 2. Topic is just being unregistered (topic is going offline)
|
|
// 2.1 Unregister it with no further action
|
|
func (h *Hub) topicUnreg(tenantID types.TenantID, sess *Session, topic string, msg *ClientComMessage, reason int) error {
|
|
now := types.TimeNow()
|
|
key := types.TopicKey{TenantID: tenantID, Topic: topic}
|
|
|
|
// TODO: when channel is deleted unsubscribe all devices from channel's FCM topic.
|
|
|
|
if reason == StopDeleted {
|
|
var asUid types.Uid
|
|
if msg != nil {
|
|
asUid = types.ParseUserId(msg.AsUser)
|
|
}
|
|
// Case 1 (unregister and delete)
|
|
if t := h.topicGet(key); t != nil {
|
|
// Case 1.1: topic is online
|
|
if (!asUid.IsZero() && t.owner == asUid) || (t.cat == types.TopicCatP2P && t.subsCount() < 2) {
|
|
// Case 1.1.1: requester is the owner or last sub in a p2p topic
|
|
t.markPaused(true)
|
|
hard := true
|
|
if msg != nil && msg.Del != nil {
|
|
// Soft-deleting does not make sense for p2p topics.
|
|
hard = msg.Del.Hard || t.cat == types.TopicCatP2P
|
|
}
|
|
if err := store.Topics.Delete(tenantID, topic, t.isChan, hard); err != nil {
|
|
t.markPaused(false)
|
|
if sess != nil {
|
|
sess.queueOut(ErrUnknownReply(msg, now))
|
|
}
|
|
return err
|
|
}
|
|
if sess != nil {
|
|
sess.queueOut(NoErrReply(msg, now))
|
|
}
|
|
|
|
if t.isChan {
|
|
// Notify channel subscribers that the channel is deleted.
|
|
sendPush(pushForChanDelete(t.tenantID, t.name, now))
|
|
}
|
|
|
|
h.topicDel(key)
|
|
t.markDeleted()
|
|
t.exit <- &shutDown{reason: StopDeleted}
|
|
statsInc("LiveTopics", -1)
|
|
} else {
|
|
// Case 1.1.2: requester is NOT the owner or not empty P2P.
|
|
msg.MetaWhat = constMsgDelTopic
|
|
msg.sess = sess
|
|
t.meta <- msg
|
|
}
|
|
} else {
|
|
// Case 1.2: topic is offline.
|
|
|
|
// Is user a channel subscriber? Use chnABC instead of grpABC and get only this user's subscription.
|
|
var opts *types.QueryOpt
|
|
if types.IsChannel(msg.Original) {
|
|
topic = msg.Original
|
|
opts = &types.QueryOpt{User: asUid}
|
|
}
|
|
|
|
// Get all subscribers of non-channel topics: we need to know how many are left and notify them.
|
|
// Get only one subscription for channel users.
|
|
subs, err := store.Topics.GetSubs(tenantID, topic, opts)
|
|
if err != nil {
|
|
sess.queueOut(ErrUnknownReply(msg, now))
|
|
return err
|
|
}
|
|
|
|
tcat := topicCat(topic)
|
|
if len(subs) == 0 {
|
|
if tcat == types.TopicCatP2P {
|
|
// No subscribers: delete.
|
|
store.Topics.Delete(tenantID, topic, false, true)
|
|
}
|
|
sess.queueOut(InfoNoActionReply(msg, now))
|
|
return nil
|
|
}
|
|
|
|
// Find subscription of the current user.
|
|
var sub *types.Subscription
|
|
user := asUid.String()
|
|
for i := range subs {
|
|
if subs[i].User == user {
|
|
sub = &subs[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if sub == nil {
|
|
// If user has no subscription, tell him all is fine
|
|
sess.queueOut(InfoNoActionReply(msg, now))
|
|
return nil
|
|
}
|
|
|
|
if !(sub.ModeGiven & sub.ModeWant).IsOwner() {
|
|
// Case 1.2.2.1 Not the owner, but possibly last subscription in a P2P topic.
|
|
|
|
if tcat == types.TopicCatP2P && len(subs) < 2 {
|
|
// This is a P2P topic and fewer than 2 subscriptions, delete the entire topic
|
|
if err := store.Topics.Delete(tenantID, topic, false, msg.Del.Hard); err != nil {
|
|
sess.queueOut(ErrUnknownReply(msg, now))
|
|
return err
|
|
}
|
|
// Inform plugin that the topic was deleted.
|
|
pluginTopic(&Topic{tenantID: tenantID, name: topic}, plgActDel)
|
|
} else if err := store.Subs.Delete(tenantID, topic, asUid); err != nil {
|
|
// Not P2P or more than 1 subscription left.
|
|
// Delete user's own subscription only
|
|
if err == types.ErrNotFound {
|
|
sess.queueOut(InfoNoActionReply(msg, now))
|
|
err = nil
|
|
} else {
|
|
sess.queueOut(ErrUnknownReply(msg, now))
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Notify user's other sessions that the subscription is gone
|
|
presSingleUserOfflineOffline(tenantID, asUid, msg.Original, "gone", nilPresParams, sess.sid)
|
|
if tcat == types.TopicCatP2P && len(subs) == 2 {
|
|
uname1 := asUid.UserId()
|
|
uid2 := types.ParseUserId(msg.Original)
|
|
// Tell user1 to stop sending updates to user2 without passing change to user1's sessions.
|
|
presSingleUserOfflineOffline(tenantID, asUid, uid2.UserId(), "?none+rem", nilPresParams, "")
|
|
// Don't change the online status of user1, just ask user2 to stop notification exchange.
|
|
// Tell user2 that user1 is offline but let him keep sending updates in case user1 resubscribes.
|
|
presSingleUserOfflineOffline(tenantID, uid2, uname1, "off", nilPresParams, "")
|
|
}
|
|
|
|
// Inform plugin that the subscription was deleted.
|
|
sub.TenantID = tenantID
|
|
pluginSubscription(sub, plgActDel)
|
|
} else {
|
|
// Case 1.2.1.1: owner, delete the group topic from db. Only group topics have owners.
|
|
// We don't know if the group topic is a channel, but cleaning it as a channel does no harm
|
|
// other than a small performance penalty.
|
|
if err := store.Topics.Delete(tenantID, topic, true, msg.Del.Hard); err != nil {
|
|
sess.queueOut(ErrUnknownReply(msg, now))
|
|
return err
|
|
}
|
|
|
|
// Notify subscribers that the group topic is gone.
|
|
presSubsOfflineOffline(tenantID, topic, tcat, subs, "gone", &presParams{}, sess.sid)
|
|
|
|
// Notify channel subscribers that the channel is deleted.
|
|
// The push will not be delivered to anybody if the topic is not a channel.
|
|
sendPush(pushForChanDelete(tenantID, topic, now))
|
|
|
|
// Inform plugin that the topic was deleted.
|
|
pluginTopic(&Topic{tenantID: tenantID, name: topic}, plgActDel)
|
|
}
|
|
|
|
sess.queueOut(NoErrReply(msg, now))
|
|
}
|
|
} else {
|
|
// Case 2: just unregister.
|
|
// If t is nil, it's not registered, no action is needed
|
|
if t := h.topicGet(key); t != nil {
|
|
t.markDeleted()
|
|
h.topicDel(key)
|
|
|
|
t.exit <- &shutDown{reason: reason}
|
|
|
|
statsInc("LiveTopics", -1)
|
|
}
|
|
|
|
// sess && msg could be nil if the topic is being killed by timer or due to rehashing.
|
|
if sess != nil && msg != nil {
|
|
sess.queueOut(NoErrReply(msg, now))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Terminate all topics associated with the given user:
|
|
// * all p2p topics with the given user
|
|
// * group topics where the given user is the owner.
|
|
// * user's 'me', 'fnd', 'slf' topics.
|
|
func (h *Hub) stopTopicsForUser(tenantID types.TenantID, uid types.Uid, reason int, alldone chan<- bool) {
|
|
var done chan bool
|
|
if alldone != nil {
|
|
done = make(chan bool, 128)
|
|
}
|
|
|
|
count := 0
|
|
h.topics.Range(func(name any, t any) bool {
|
|
topic := t.(*Topic)
|
|
if topic.tenantID != tenantID {
|
|
return true
|
|
}
|
|
if _, isMember := topic.perUser[uid]; (topic.cat != types.TopicCatGrp && isMember) ||
|
|
topic.owner == uid {
|
|
topic.markDeleted()
|
|
h.topics.Delete(name)
|
|
|
|
// This call is non-blocking unless some other routine tries to stop it at the same time.
|
|
topic.exit <- &shutDown{reason: reason, done: done}
|
|
|
|
// Just send to p2p topics here.
|
|
if topic.cat == types.TopicCatP2P && len(topic.perUser) == 2 {
|
|
presSingleUserOfflineOffline(tenantID, topic.p2pOtherUser(uid), uid.UserId(), "gone", nilPresParams, "")
|
|
}
|
|
count++
|
|
}
|
|
return true
|
|
})
|
|
|
|
statsInc("LiveTopics", -count)
|
|
|
|
if alldone != nil {
|
|
for range count {
|
|
<-done
|
|
}
|
|
alldone <- true
|
|
}
|
|
}
|
|
|
|
// replyOfflineTopicGetDesc reads a minimal topic Desc from the database.
|
|
// The requester may or maynot be subscribed to the topic.
|
|
func replyOfflineTopicGetDesc(sess *Session, msg *ClientComMessage) {
|
|
now := types.TimeNow()
|
|
desc := &MsgTopicDesc{}
|
|
asUid := types.ParseUserId(msg.AsUser)
|
|
topic := msg.RcptTo
|
|
|
|
if strings.HasPrefix(topic, "grp") || topic == "sys" {
|
|
stopic, err := store.Topics.Get(msg.TenantID, topic)
|
|
if err != nil {
|
|
logs.Info.Println("replyOfflineTopicGetDesc", err)
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
return
|
|
}
|
|
if stopic == nil {
|
|
sess.queueOut(ErrTopicNotFoundReply(msg, now))
|
|
return
|
|
}
|
|
|
|
desc.CreatedAt = &stopic.CreatedAt
|
|
desc.UpdatedAt = &stopic.UpdatedAt
|
|
desc.Public = stopic.Public
|
|
desc.Trusted = stopic.Trusted
|
|
desc.IsChan = stopic.UseBt
|
|
desc.SubCnt = stopic.SubCnt
|
|
if stopic.Owner == msg.AsUser {
|
|
desc.DefaultAcs = &MsgDefaultAcsMode{
|
|
Auth: stopic.Access.Auth.String(),
|
|
Anon: stopic.Access.Anon.String(),
|
|
}
|
|
}
|
|
// Report appropriate access level. Could be overridden below if subscription exists.
|
|
desc.Acs = &MsgAccessMode{}
|
|
switch sess.authLvl {
|
|
case auth.LevelAuth, auth.LevelRoot:
|
|
desc.Acs.Mode = stopic.Access.Auth.String()
|
|
case auth.LevelAnon:
|
|
desc.Acs.Mode = stopic.Access.Anon.String()
|
|
}
|
|
} else {
|
|
// 'me' and p2p topics
|
|
uid := types.ZeroUid
|
|
if strings.HasPrefix(topic, "usr") {
|
|
// User specified as usrXXX
|
|
uid = types.ParseUserId(topic)
|
|
topic = asUid.P2PName(uid)
|
|
} else if strings.HasPrefix(topic, "p2p") {
|
|
// User specified as p2pXXXYYY
|
|
uid1, uid2, _ := types.ParseP2P(topic)
|
|
if uid1 == asUid {
|
|
uid = uid2
|
|
} else if uid2 == asUid {
|
|
uid = uid1
|
|
}
|
|
}
|
|
|
|
if uid.IsZero() {
|
|
logs.Warn.Println("replyOfflineTopicGetDesc: malformed p2p topic name")
|
|
sess.queueOut(ErrMalformedReply(msg, now))
|
|
return
|
|
}
|
|
|
|
suser, err := store.Users.Get(msg.TenantID, uid)
|
|
if err != nil {
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
return
|
|
}
|
|
if suser == nil {
|
|
sess.queueOut(ErrUserNotFoundReply(msg, now))
|
|
return
|
|
}
|
|
desc.CreatedAt = &suser.CreatedAt
|
|
desc.UpdatedAt = &suser.UpdatedAt
|
|
desc.Public = suser.Public
|
|
desc.Trusted = suser.Trusted
|
|
if sess.authLvl == auth.LevelRoot {
|
|
desc.State = suser.State.String()
|
|
}
|
|
|
|
// Report appropriate access level. Could be overridden below if subscription exists.
|
|
desc.Acs = &MsgAccessMode{}
|
|
switch sess.authLvl {
|
|
case auth.LevelAuth, auth.LevelRoot:
|
|
desc.Acs.Mode = suser.Access.Auth.String()
|
|
case auth.LevelAnon:
|
|
desc.Acs.Mode = suser.Access.Anon.String()
|
|
}
|
|
}
|
|
|
|
sub, err := store.Subs.Get(msg.TenantID, topic, asUid, false)
|
|
if err != nil {
|
|
logs.Warn.Println("replyOfflineTopicGetDesc:", err)
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
return
|
|
}
|
|
|
|
if sub != nil {
|
|
desc.Private = sub.Private
|
|
// FIXME: suspended topics should get no AW access.
|
|
desc.Acs = &MsgAccessMode{
|
|
Want: sub.ModeWant.String(),
|
|
Given: sub.ModeGiven.String(),
|
|
Mode: (sub.ModeGiven & sub.ModeWant).String(),
|
|
}
|
|
}
|
|
|
|
sess.queueOut(&ServerComMessage{
|
|
Meta: &MsgServerMeta{
|
|
Id: msg.Id, Topic: msg.Original, Timestamp: &now, Desc: desc,
|
|
},
|
|
})
|
|
}
|
|
|
|
// replyOfflineTopicGetSub reads user's subscription from the database.
|
|
// Only own subscription is available.
|
|
// The requester must be subscribed but need not be attached.
|
|
func replyOfflineTopicGetSub(sess *Session, msg *ClientComMessage) {
|
|
now := types.TimeNow()
|
|
|
|
if msg.Get.Sub != nil && msg.Get.Sub.User != "" && msg.Get.Sub.User != msg.AsUser {
|
|
sess.queueOut(ErrPermissionDeniedReply(msg, now))
|
|
return
|
|
}
|
|
|
|
topicName := msg.RcptTo
|
|
if types.IsChannel(msg.Original) {
|
|
topicName = msg.Original
|
|
}
|
|
|
|
ssub, err := store.Subs.Get(msg.TenantID, topicName, types.ParseUserId(msg.AsUser), true)
|
|
if err != nil {
|
|
logs.Warn.Println("replyOfflineTopicGetSub:", err)
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
return
|
|
}
|
|
|
|
if ssub == nil {
|
|
sess.queueOut(ErrNotFoundExplicitTs(msg.Id, msg.Original, now, msg.Timestamp))
|
|
return
|
|
}
|
|
|
|
sub := MsgTopicSub{}
|
|
if ssub.DeletedAt == nil {
|
|
sub.UpdatedAt = &ssub.UpdatedAt
|
|
sub.Acs = MsgAccessMode{
|
|
Want: ssub.ModeWant.String(),
|
|
Given: ssub.ModeGiven.String(),
|
|
Mode: (ssub.ModeGiven & ssub.ModeWant).String(),
|
|
}
|
|
// Fnd is asymmetric: desc.private is a string, but sub.private is a []string.
|
|
if types.GetTopicCat(msg.RcptTo) != types.TopicCatFnd {
|
|
sub.Private = ssub.Private
|
|
}
|
|
sub.User = types.ParseUid(ssub.User).UserId()
|
|
|
|
if (ssub.ModeGiven & ssub.ModeWant).IsReader() && (ssub.ModeWant & ssub.ModeGiven).IsJoiner() {
|
|
sub.DelId = ssub.DelId
|
|
sub.ReadSeqId = ssub.ReadSeqId
|
|
sub.RecvSeqId = ssub.RecvSeqId
|
|
}
|
|
} else {
|
|
sub.DeletedAt = ssub.DeletedAt
|
|
}
|
|
|
|
sess.queueOut(&ServerComMessage{
|
|
Meta: &MsgServerMeta{
|
|
Id: msg.Id, Topic: msg.Original, Timestamp: &now, Sub: []MsgTopicSub{sub},
|
|
},
|
|
})
|
|
}
|
|
|
|
// replyOfflineTopicSetSub updates Desc.Private and Sub.Mode when the topic is not loaded in memory.
|
|
// Only Private and Mode are updated and only for the requester. The requester must be subscribed to the
|
|
// topic but does not need to be attached.
|
|
func replyOfflineTopicSetSub(sess *Session, msg *ClientComMessage) {
|
|
now := types.TimeNow()
|
|
|
|
if (msg.Set.Desc == nil || msg.Set.Desc.Private == nil) && (msg.Set.Sub == nil || msg.Set.Sub.Mode == "") {
|
|
sess.queueOut(InfoNotModifiedReply(msg, now))
|
|
return
|
|
}
|
|
|
|
if msg.Set.Sub != nil && msg.Set.Sub.User != "" && msg.Set.Sub.User != msg.AsUser {
|
|
sess.queueOut(ErrPermissionDeniedReply(msg, now))
|
|
return
|
|
}
|
|
|
|
asUid := types.ParseUserId(msg.AsUser)
|
|
|
|
topicName := msg.RcptTo
|
|
if types.IsChannel(msg.Original) {
|
|
topicName = msg.Original
|
|
}
|
|
|
|
sub, err := store.Subs.Get(msg.TenantID, topicName, asUid, false)
|
|
if err != nil {
|
|
logs.Warn.Println("replyOfflineTopicSetSub get sub:", err)
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
return
|
|
}
|
|
|
|
if sub == nil {
|
|
sess.queueOut(ErrNotFoundExplicitTs(msg.Id, msg.Original, now, msg.Timestamp))
|
|
return
|
|
}
|
|
|
|
update := make(map[string]any)
|
|
if msg.Set.Desc != nil && msg.Set.Desc.Private != nil {
|
|
private, ok := msg.Set.Desc.Private.(map[string]any)
|
|
if !ok {
|
|
update = map[string]any{"Private": msg.Set.Desc.Private}
|
|
} else if private, changed := mergeInterfaces(sub.Private, private); changed {
|
|
update = map[string]any{"Private": private}
|
|
}
|
|
}
|
|
|
|
if msg.Set.Sub != nil && msg.Set.Sub.Mode != "" {
|
|
var modeWant types.AccessMode
|
|
if err = modeWant.UnmarshalText([]byte(msg.Set.Sub.Mode)); err != nil {
|
|
logs.Warn.Println("replyOfflineTopicSetSub mode:", err)
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
return
|
|
}
|
|
|
|
if modeWant.IsOwner() != sub.ModeWant.IsOwner() {
|
|
// No ownership changes here.
|
|
sess.queueOut(ErrPermissionDeniedReply(msg, now))
|
|
return
|
|
}
|
|
|
|
if types.GetTopicCat(msg.RcptTo) == types.TopicCatP2P {
|
|
// For P2P topics ignore requests exceeding typesModeCP2P and do not allow
|
|
// removal of 'A' permission.
|
|
modeWant = modeWant&globals.typesModeCP2P | types.ModeApprove
|
|
}
|
|
|
|
if modeWant != sub.ModeWant {
|
|
update["ModeWant"] = modeWant
|
|
// Cache it for later use
|
|
sub.ModeWant = modeWant
|
|
}
|
|
}
|
|
|
|
if len(update) > 0 {
|
|
err = store.Subs.Update(msg.TenantID, topicName, asUid, update)
|
|
if err != nil {
|
|
logs.Warn.Println("replyOfflineTopicSetSub update:", err)
|
|
sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil))
|
|
} else {
|
|
var params any
|
|
if update["ModeWant"] != nil {
|
|
params = map[string]any{
|
|
"acs": MsgAccessMode{
|
|
Given: sub.ModeGiven.String(),
|
|
Want: sub.ModeWant.String(),
|
|
Mode: (sub.ModeGiven & sub.ModeWant).String(),
|
|
},
|
|
}
|
|
}
|
|
sess.queueOut(NoErrParamsReply(msg, now, params))
|
|
}
|
|
} else {
|
|
sess.queueOut(InfoNotModifiedReply(msg, now))
|
|
}
|
|
}
|