diff --git a/server/topic.go b/server/topic.go index e69de29..c88d33a 100644 --- a/server/topic.go +++ b/server/topic.go @@ -0,0 +1,4067 @@ +/****************************************************************************** + * + * Description : + * An isolated communication channel (chat room, 1:1 conversation) for + * usually multiple users. There is no communication across topics. + * + *****************************************************************************/ + +package main + +import ( + "errors" + "sort" + "strings" + "sync/atomic" + "time" + + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +// Topic is an isolated communication channel +type Topic struct { + // Tenant owning this topic. Set once when the topic is initialized. + tenantID types.TenantID + // Еxpanded/unique name of the topic. + name string + // For single-user topics session-specific topic name, such as 'me', + // otherwise the same as 'name'. + xoriginal string + + // Topic category + cat types.TopicCat + + // Name of the master node for this topic if isProxy is true. + masterNode string + + // Time when the topic was first created. + created time.Time + // Time when the topic was last updated. + updated time.Time + // Time of the last outgoing message. + touched time.Time + + // Server-side ID of the last data message + lastID int + // ID of the deletion operation. Not an ID of the message. + delID int + + // Total count of subscribers (excluding deleted). + // This is different from subsCount() for channels. + subCnt int + + // Last published userAgent ('me' topic only) + userAgent string + + // User ID of the topic owner/creator. Could be zero. + owner types.Uid + + // Default access mode + accessAuth types.AccessMode + accessAnon types.AccessMode + + // Topic discovery tags + tags []string + + // Auxiliary set of key-value pairs + aux map[string]any + + // Topic's public data + public any + // Topic's trusted data + trusted any + + // Topic's per-subscriber data + perUser map[types.Uid]perUserData + // Union of permissions across all users (used by proxy sessions with uid = 0). + // These are used by master topics only (in the proxy-master topic context) + // as a coarse-grained attempt to perform acs checks since proxy sessions "impersonate" + // multiple normal sessions (uids) which may have different uids. + modeWantUnion types.AccessMode + modeGivenUnion types.AccessMode + + // User's contact list (not nil for 'me' topic only). + // The map keys are UserIds for P2P topics and grpXXX for group topics. + perSubs map[string]perSubsData + + // Sessions attached to this topic. The UID kept here may not match Session.uid if session is + // subscribed on behalf of another user. + sessions map[*Session]perSessionData + + // Present video call data. Null when there's no call in progress or being established. + // Only available for p2p topics. + currentCall *videoCall + + // Channel for receiving client messages from sessions or other topics, buffered = 256. + clientMsg chan *ClientComMessage + // Channel for receiving server messages generated on the server or received from other cluster nodes, buffered = 64. + serverMsg chan *ServerComMessage + // Channel for receiving {get}/{set}/{del} requests, buffered = 64 + meta chan *ClientComMessage + // Subscribe requests from sessions, buffered = 256 + reg chan *ClientComMessage + // Unsubscribe requests from sessions, buffered = 256 + unreg chan *ClientComMessage + // Session updates: background sessions coming online, User Agent changes. Buffered = 32 + supd chan *sessionUpdate + // Channel to terminate topic -- either the topic is deleted or system is being shut down. Buffered = 1. + exit chan *shutDown + // Channel to receive topic master responses (used only by proxy topics). + proxy chan *ClusterResp + // Channel to receive topic proxy service requests, e.g. sending deferred notifications. + master chan *ClusterSessUpdate + + // Flag which tells topic lifecycle status: new, ready, paused, marked for deletion. + status int32 + + // Channel functionality is enabled for the group topic. + isChan bool + + // If isProxy == true, the actual topic is hosted by another cluster member. + // The topic should: + // 1. forward all messages to master + // 2. route replies from the master to sessions. + // 3. disconnect sessions at master's request. + // 4. shut down the topic at master's request. + // 5. aggregate access permissions on behalf of attached sessions. + isProxy bool + + // Countdown timer for destroying the topic when there are no more attached sessions to it. + killTimer *time.Timer + + // Countdown timer for terminating iniatated (but not established) calls. + callEstablishmentTimer *time.Timer +} + +// perUserData holds topic's cache of per-subscriber data +type perUserData struct { + // Count of subscription online and announced (presence not deferred). + online int + + // Last t.lastId reported by user through {pres} as received or read + recvID int + readID int + // ID of the latest Delete operation + delID int + + private any + + modeWant types.AccessMode + modeGiven types.AccessMode + + // P2P only: + public any + trusted any + lastSeen *time.Time + lastUA string + + topicName string + deleted bool + + // The user is a channel subscriber. + isChan bool +} + +// perSubsData holds user's (on 'me' topic) cache of subscription data +type perSubsData struct { + // The other user's/topic's online status as seen by this user. + online bool + // True if we care about the updates from the other user/topic: (want&given).IsPresencer(). + // Does not affect sending notifications from this user to other users. + enabled bool +} + +// Data related to a subscription of a session to a topic. +type perSessionData struct { + // ID of the subscribed user (asUid); not necessarily the session owner. + // Could be zero for multiplexed sessions in cluster. + uid types.Uid + // This is a channel subscription + isChanSub bool + // IDs of subscribed users in a multiplexing session. + muids []types.Uid +} + +// Reasons why topic is being shut down. +const ( + // StopNone no reason given/default. + StopNone = iota + // StopShutdown terminated due to system shutdown. + StopShutdown + // StopDeleted terminated due to being deleted. + StopDeleted + // StopRehashing terminated due to cluster rehashing (moved to a different node). + StopRehashing +) + +// Topic shutdown +type shutDown struct { + // Channel to report back completion of topic shutdown. Could be nil + done chan<- bool + // Topic is being deleted as opposite to total system shutdown + reason int +} + +// Session update: user agent change or background session becoming normal. +// If sess is nil then user agent change, otherwise bg to fg update. +type sessionUpdate struct { + sess *Session + userAgent string +} + +var ( + nilPresParams = &presParams{} + nilPresFilters = &presFilters{} +) + +func (t *Topic) run(hub *Hub) { + if !t.isProxy { + t.runLocal(hub) + } else { + t.runProxy(hub) + } +} + +// getPerUserAcs returns `want` and `given` permissions for the given user id. +func (t *Topic) getPerUserAcs(uid types.Uid) (types.AccessMode, types.AccessMode) { + if uid.IsZero() { + // For zero uids (typically for proxy sessions), return the union of all permissions. + return t.modeWantUnion, t.modeGivenUnion + } + pud := t.perUser[uid] + return pud.modeWant, pud.modeGiven +} + +// passesPresenceFilters applies presence filters to `msg` +// depending on per-user want and given acls for the provided `uid`. +func (t *Topic) passesPresenceFilters(pres *MsgServerPres, uid types.Uid) bool { + modeWant, modeGiven := t.getPerUserAcs(uid) + // "gone" and "acs" notifications are sent even if the topic is muted. + return ((modeGiven & modeWant).IsPresencer() || pres.What == "gone" || pres.What == "acs") && + (pres.FilterIn == 0 || int(modeGiven&modeWant)&pres.FilterIn != 0) && + (pres.FilterOut == 0 || int(modeGiven&modeWant)&pres.FilterOut == 0) +} + +// userIsReader returns true if the user (specified by `uid`) may read the given topic. +func (t *Topic) userIsReader(uid types.Uid) bool { + modeWant, modeGiven := t.getPerUserAcs(uid) + return (modeGiven & modeWant).IsReader() +} + +// prepareBroadcastableMessage sets the topic field in `msg` depending on the uid and subscription type. +func (t *Topic) prepareBroadcastableMessage(msg *ServerComMessage, uid types.Uid, isChanSub bool) { + msg.TenantID = t.tenantID + // We are only interested in broadcastable messages. + if msg.Data == nil && msg.Pres == nil && msg.Info == nil { + return + } + + if (t.cat == types.TopicCatP2P && !uid.IsZero()) || (t.cat == types.TopicCatGrp && t.isChan) { + // For p2p topics topic name is dependent on receiver. + // Channel topics may be presented as grpXXX or chnXXX. + var topicName string + if isChanSub { + topicName = types.GrpToChn(t.xoriginal) + } else { + topicName = t.original(uid) + } + switch { + case msg.Data != nil: + msg.Data.Topic = topicName + case msg.Pres != nil: + msg.Pres.Topic = topicName + case msg.Info != nil: + msg.Info.Topic = topicName + } + } + + // Send channel messages anonymously. + if isChanSub && msg.Data != nil { + msg.Data.From = "" + } +} + +// computePerUserAcsUnion computes want and given permissions unions over all topic's subscribers. +func (t *Topic) computePerUserAcsUnion() { + wantUnion := types.ModeNone + givenUnion := types.ModeNone + for _, pud := range t.perUser { + if pud.isChan { + continue + } + wantUnion |= pud.modeWant + givenUnion |= pud.modeGiven + } + + if t.isChan { + // Apply standard channel permissions to channel topics. + wantUnion |= types.ModeCChnReader + givenUnion |= types.ModeCChnReader + } + + t.modeWantUnion = wantUnion + t.modeGivenUnion = givenUnion +} + +// unregisterSession implements all logic following receipt of a leave +// request via the Topic.unreg channel. +func (t *Topic) unregisterSession(msg *ClientComMessage) { + if t.currentCall != nil { + shouldTerminateCall := false + if msg.sess.isMultiplex() { + // Check if any of the call party sessions is multiplexed over msg.sess. + for _, p := range t.currentCall.parties { + if p.sess.isProxy() && p.sess.multi == msg.sess { + shouldTerminateCall = true + break + } + } + } else if _, found := t.currentCall.parties[msg.sess.sid]; found { + // Normal session disconnecting from topic. Just terminate the call. + shouldTerminateCall = true + } + if shouldTerminateCall { + t.terminateCallInProgress(false) + } + } + t.handleLeaveRequest(msg, msg.sess) + if msg.init && msg.sess.inflightReqs != nil { + // If it's a client initiated request. + msg.sess.inflightReqs.Done() + } + + // If there are no more subscriptions to this topic, start a kill timer + if len(t.sessions) == 0 && t.cat != types.TopicCatSys { + t.killTimer.Reset(idleMasterTopicTimeout) + } +} + +// registerSession handles a session join (registration) request +// received via the Topic.reg channel. +func (t *Topic) registerSession(msg *ClientComMessage) { + // Request to add a connection to this topic + if t.isInactive() { + msg.sess.queueOut(ErrLockedReply(msg, types.TimeNow())) + } else if msg.sess.getSub(t.name) != nil { + // Session is already subscribed to topic. Subscription is checked in session.go, + // but there is a gap between topic creation/un-pausing and processing the + // first subscription request, before the topic is linked to session: a client + // may send several subscription requests in that gap. + msg.sess.queueOut(InfoAlreadySubscribed(msg.Id, msg.Original, msg.Timestamp)) + } else { + // The topic is alive, so stop the kill timer, if it's ticking. We don't want the topic to die + // while processing the call. + t.killTimer.Stop() + if err := t.handleSubscription(msg); err == nil { + if msg.Sub.Created { + // Call plugins with the new topic + pluginTopic(t, plgActCreate) + } + } else { + if len(t.sessions) == 0 && t.cat != types.TopicCatSys { + // Failed to subscribe, the topic is still inactive + t.killTimer.Reset(idleMasterTopicTimeout) + } + logs.Warn.Printf("topic[%s] subscription failed %v, sid=%s", t.name, err, msg.sess.sid) + } + } + if msg.sess.inflightReqs != nil { + msg.sess.inflightReqs.Done() + } +} + +func (t *Topic) handleMetaGet(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) { + if msg.MetaWhat&constMsgMetaDesc != 0 { + if err := t.replyGetDesc(msg.sess, asUid, asChan, msg.Get.Desc, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Desc failed: %s", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaSub != 0 { + if err := t.replyGetSub(msg.sess, asUid, authLevel, asChan, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Sub failed: %s", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaData != 0 { + if err := t.replyGetData(msg.sess, asUid, asChan, msg.Get.Data, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Data failed: %s", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaDel != 0 { + if err := t.replyGetDel(msg.sess, asUid, msg.Get.Del, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Del failed: %s", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaTags != 0 { + if err := t.replyGetTags(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Tags failed: %s", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaCred != 0 { + if err := t.replyGetCreds(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Creds failed: %s", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaAux != 0 { + logs.Warn.Printf("topic[%s] handle getAux", t.name) + if err := t.replyGetAux(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Get.Aux failed: %s", t.name, err) + } + } +} + +func (t *Topic) handleMetaSet(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) { + if msg.MetaWhat&constMsgMetaDesc != 0 { + if err := t.replySetDesc(msg.sess, asUid, asChan, authLevel, msg); err == nil { + // Notify plugins of the update + pluginTopic(t, plgActUpd) + } else { + logs.Warn.Printf("topic[%s] meta.Set.Desc failed: %v", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaSub != 0 { + if err := t.replySetSub(msg.sess, msg, asChan); err != nil { + logs.Warn.Printf("topic[%s] meta.Set.Sub failed: %v", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaTags != 0 { + if err := t.replySetTags(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Set.Tags failed: %v", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaCred != 0 { + if err := t.replySetCred(msg.sess, asUid, authLevel, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Set.Cred failed: %v", t.name, err) + } + } + if msg.MetaWhat&constMsgMetaAux != 0 { + if err := t.replySetAux(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] meta.Set.Aux failed: %v", t.name, err) + } + } +} + +func (t *Topic) handleMetaDel(msg *ClientComMessage, asUid types.Uid, asChan bool, authLevel auth.Level) { + var err error + switch msg.MetaWhat { + case constMsgDelMsg: + err = t.replyDelMsg(msg.sess, asUid, asChan, msg) + case constMsgDelSub: + err = t.replyDelSub(msg.sess, asUid, msg) + case constMsgDelTopic: + err = t.replyDelTopic(msg.sess, asUid, msg) + case constMsgDelCred: + err = t.replyDelCred(msg.sess, asUid, authLevel, msg) + } + + if err != nil { + logs.Warn.Printf("topic[%s] meta.Del failed: %v", t.name, err) + } +} + +// handleMeta implements logic handling meta requests +// received via the Topic.meta channel. +func (t *Topic) handleMeta(msg *ClientComMessage) { + // Request to get/set topic metadata + asUid := types.ParseUserId(msg.AsUser) + authLevel := auth.Level(msg.AuthLvl) + asChan, err := t.verifyChannelAccess(msg.Original) + if err != nil { + // User should not be able to address non-channel topic as channel. + msg.sess.queueOut(ErrNotFoundReply(msg, types.TimeNow())) + return + } + switch { + case msg.Get != nil: + // Get request + t.handleMetaGet(msg, asUid, asChan, authLevel) + + case msg.Set != nil: + // Set request + t.handleMetaSet(msg, asUid, asChan, authLevel) + + case msg.Del != nil: + // Del request + t.handleMetaDel(msg, asUid, asChan, authLevel) + } +} + +func (t *Topic) handleSessionUpdate(upd *sessionUpdate, currentUA *string, uaTimer *time.Timer) { + if upd.sess != nil { + // 'me' & 'grp' only. Background session timed out and came online. + t.sessToForeground(upd.sess) + } else if *currentUA != upd.userAgent { + if t.cat != types.TopicCatMe { + logs.Warn.Panicln("invalid topic category in UA update", t.name) + } + // 'me' only. Process an update to user agent from one of the sessions. + *currentUA = upd.userAgent + uaTimer.Reset(uaTimerDelay) + } +} + +func (t *Topic) handleUATimerEvent(currentUA string) { + // Publish user agent changes after a delay + if currentUA == "" || currentUA == t.userAgent { + return + } + t.userAgent = currentUA + t.presUsersOfInterest("ua", t.userAgent) +} + +func (t *Topic) handleTopicTimeout(hub *Hub, currentUA string, uaTimer, defrNotifTimer *time.Timer) { + // Topic timeout + hub.unreg <- &topicUnreg{tenantID: t.tenantID, rcptTo: t.name} + defrNotifTimer.Stop() + switch t.cat { + case types.TopicCatMe: + uaTimer.Stop() + t.presUsersOfInterest("off", currentUA) + case types.TopicCatGrp: + t.presSubsOffline("off", nilPresParams, nilPresFilters, nilPresFilters, "", false) + } +} + +func (t *Topic) handleTopicTermination(sd *shutDown) { + // Handle four cases: + // 1. Topic is shutting down by timer due to inactivity (reason == StopNone) + // 2. Topic is being deleted (reason == StopDeleted) + // 3. System shutdown (reason == StopShutdown, done != nil). + // 4. Cluster rehashing (reason == StopRehashing) + + switch sd.reason { + case StopDeleted: + if t.cat == types.TopicCatGrp { + t.presSubsOffline("gone", nilPresParams, nilPresFilters, nilPresFilters, "", false) + } + // P2P users get "off+remove" earlier in the process + + // Inform plugins that the topic is deleted + pluginTopic(t, plgActDel) + + case StopRehashing: + // Must send individual messages to sessions because normal sending through the topic's + // broadcast channel won't work - it will be shut down too soon. + t.presSubsOnlineDirect("term", nilPresParams, nilPresFilters, "") + } + // In case of a system shutdown don't bother with notifications. They won't be delivered anyway. + + // Tell sessions to remove the topic + for s := range t.sessions { + s.detachSession(t.name) + } + + if t.cat == types.TopicCatGrp { + // Update topic subscriber count. + if err := store.Topics.UpdateSubCnt(t.tenantID, t.name); err != nil { + logs.Warn.Println("topic update sub cnt:", err) + } + } + + usersRegisterTopic(t, false) + + // Report completion back to sender, if 'done' is not nil. + if sd.done != nil { + sd.done <- true + } +} + +func (t *Topic) runLocal(hub *Hub) { + // Kills topic after a period of inactivity. + t.killTimer = time.NewTimer(time.Hour) + t.killTimer.Stop() + + // Notifies about user agent change. 'me' only + uaTimer := time.NewTimer(time.Minute) + var currentUA string + uaTimer.Stop() + + // Ticker for deferred presence notifications. + defrNotifTimer := time.NewTimer(time.Millisecond * 500) + + t.callEstablishmentTimer = time.NewTimer(time.Second) + t.callEstablishmentTimer.Stop() + + for { + select { + case msg := <-t.reg: + t.registerSession(msg) + + case msg := <-t.unreg: + t.unregisterSession(msg) + + case msg := <-t.clientMsg: + t.handleClientMsg(msg) + + case msg := <-t.serverMsg: + t.handleServerMsg(msg) + + case meta := <-t.meta: + t.handleMeta(meta) + + case upd := <-t.supd: + t.handleSessionUpdate(upd, ¤tUA, uaTimer) + + case <-uaTimer.C: + t.handleUATimerEvent(currentUA) + + case <-t.killTimer.C: + t.handleTopicTimeout(hub, currentUA, uaTimer, defrNotifTimer) + + case <-t.callEstablishmentTimer.C: + t.terminateCallInProgress(true) + + case sd := <-t.exit: + t.handleTopicTermination(sd) + return + } + } +} + +// handleClientMsg is the top-level handler of messages received by the topic from sessions. +func (t *Topic) handleClientMsg(msg *ClientComMessage) { + if msg.TenantID.IsZero() { + msg.TenantID = t.tenantID + } else if msg.TenantID != t.tenantID { + logs.Warn.Printf("topic[%s]: rejected cross-tenant client message: topic=%d message=%d", t.name, t.tenantID, msg.TenantID) + return + } + if msg.Pub != nil { + t.handlePubBroadcast(msg) + } else if msg.Note != nil { + t.handleNoteBroadcast(msg) + } else { + // TODO(gene): maybe remove this panic. + logs.Err.Panic("topic: wrong client message type for broadcasting", t.name) + } +} + +// handleServerMsg is the top-level handler of messages generated at the server. +func (t *Topic) handleServerMsg(msg *ServerComMessage) { + if msg.TenantID.IsZero() { + msg.TenantID = t.tenantID + } else if msg.TenantID != t.tenantID { + logs.Warn.Printf("topic[%s]: rejected cross-tenant server message: topic=%d message=%d", t.name, t.tenantID, msg.TenantID) + return + } + // Server-generated message: {info} or {pres}. + if t.isInactive() { + // Ignore message - the topic is paused or being deleted. + return + } + if msg.Pres != nil { + t.handlePresence(msg) + } else if msg.Info != nil { + t.broadcastToSessions(msg) + } else { + // TODO(gene): maybe remove this panic. + logs.Err.Panic("topic: wrong server message type for broadcasting", t.name) + } +} + +// Session subscribed to a topic, created == true if topic was just created and {pres} needs to be announced +func (t *Topic) handleSubscription(msg *ClientComMessage) error { + asUid := types.ParseUserId(msg.AsUser) + authLevel := auth.Level(msg.AuthLvl) + asChan, err := t.verifyChannelAccess(msg.Original) + if err != nil { + // User should not be able to address non-channel topic as channel. + msg.sess.queueOut(ErrNotFoundReply(msg, types.TimeNow())) + return err + } + + if err := t.subscriptionReply(asChan, msg); err != nil { + return err + } + + msgsub := msg.Sub + getWhat := 0 + if msgsub.Get != nil { + getWhat = parseMsgClientMeta(msgsub.Get.What) + } + if getWhat&constMsgMetaDesc != 0 { + // Send get.desc as a {meta} packet. + if err := t.replyGetDesc(msg.sess, asUid, asChan, msgsub.Get.Desc, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Desc failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + if getWhat&constMsgMetaSub != 0 { + // Send get.sub response as a separate {meta} packet + if err := t.replyGetSub(msg.sess, asUid, authLevel, asChan, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Sub failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + if getWhat&constMsgMetaTags != 0 { + // Send get.tags response as a separate {meta} packet + if err := t.replyGetTags(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Tags failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + if getWhat&constMsgMetaCred != 0 { + // Send get.tags response as a separate {meta} packet + if err := t.replyGetCreds(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Cred failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + if getWhat&constMsgMetaAux != 0 { + // Send get.aux response as a separate {meta} packet + if err := t.replyGetAux(msg.sess, asUid, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Aux failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + if getWhat&constMsgMetaData != 0 { + // Send get.data response as {data} packets + if err := t.replyGetData(msg.sess, asUid, asChan, msgsub.Get.Data, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Data failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + if getWhat&constMsgMetaDel != 0 { + // Send get.del response as a separate {meta} packet + if err := t.replyGetDel(msg.sess, asUid, msgsub.Get.Del, msg); err != nil { + logs.Warn.Printf("topic[%s] handleSubscription Get.Del failed: %v sid=%s", t.name, err, msg.sess.sid) + } + } + + return nil +} + +// handleLeaveRequest processes a session leave request. +func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) { + // Remove connection from topic; session may continue to function + now := types.TimeNow() + + var asUid types.Uid + var asChan bool + if msg.init { + asUid = types.ParseUserId(msg.AsUser) + var err error + asChan, err = t.verifyChannelAccess(msg.Original) + if err != nil { + // Group topic cannot be addressed as channel unless channel functionality is enabled. + sess.queueOut(ErrNotFoundReply(msg, now)) + } + } + + if t.isInactive() { + if !asUid.IsZero() && msg.init { + sess.queueOut(ErrLockedReply(msg, now)) + } + return + } + + // User wants to leave and unsubscribe. + if msg.init && msg.Leave.Unsub { + // asUid must not be Zero. + if err := t.replyLeaveUnsub(sess, msg, asUid); err != nil { + logs.Err.Println("failed to unsub", err, sess.sid) + } + return + } + + // User wants to leave without unsubscribing. + if pssd, _ := t.remSession(sess, asUid); pssd != nil { + if !sess.isProxy() { + sess.delSub(t.name) + } + if pssd.isChanSub != asChan { + // Cannot address non-channel subscription as channel and vice versa. + if msg.init { + // Group topic cannot be addressed as channel unless channel functionality is enabled. + sess.queueOut(ErrNotFoundReply(msg, now)) + } + return + } + + var uid types.Uid + if sess.isProxy() { + // Multiplexing session, multiple UIDs. + uid = asUid + } else { + // Simple session, single UID. + uid = pssd.uid + } + + var pud perUserData + // uid may be zero when a proxy session is trying to terminate (it called unsubAll). + if !uid.IsZero() { + // UID not zero: one user removed. + pud = t.perUser[uid] + if !sess.background { + pud.online-- + t.perUser[uid] = pud + } + } else if len(pssd.muids) > 0 { + // UID is zero: multiplexing session is dropped altogether. + // Using new 'uid' and 'pud' variables. + for _, uid := range pssd.muids { + pud := t.perUser[uid] + pud.online-- + t.perUser[uid] = pud + } + } else if !sess.isCluster() { + logs.Warn.Panic("cannot determine uid: leave req", msg, sess) + } + + switch t.cat { + case types.TopicCatMe: + mrs := t.mostRecentSession() + if mrs == nil { + // Last session + mrs = sess + } else { + // Change UA to the most recent live session and announce it. Don't block. + select { + case t.supd <- &sessionUpdate{userAgent: mrs.userAgent}: + default: + } + } + + meUid := uid + if meUid.IsZero() && len(pssd.muids) > 0 { + // The entire multiplexing session is being dropped. Need to find owner's UID. + // len(pssd.muids) could be zero if the session was a background session. + meUid = pssd.muids[0] + } + if !meUid.IsZero() { + // Update user's last online timestamp & user agent. Only one user can be subscribed to 'me' topic. + if err := store.Users.UpdateLastSeen(t.tenantID, meUid, mrs.userAgent, now); err != nil { + logs.Warn.Println("user update last seen:", err) + } + } + case types.TopicCatFnd: + // FIXME: this does not work correctly in case of a multiplexing session. + // Remove ephemeral query. + t.fndRemovePublic(sess) + case types.TopicCatGrp: + // Subscriber is going offline in the topic: notify other subscribers who are currently online. + readFilter := &presFilters{filterIn: types.ModeRead} + if !uid.IsZero() { + if pud.online == 0 { + if asChan { + // Simply delete record from perUserData + delete(t.perUser, uid) + } else { + t.presSubsOnline("off", uid.UserId(), nilPresParams, readFilter, "") + } + } + } else if len(pssd.muids) > 0 { + for _, uid := range pssd.muids { + if t.perUser[uid].online == 0 { + if asChan { + // delete record from perUserData + delete(t.perUser, uid) + } else { + t.presSubsOnline("off", uid.UserId(), nilPresParams, readFilter, "") + } + } + } + } + } + + if !uid.IsZero() { + // Respond if contains an id. + if msg.init { + sess.queueOut(NoErrReply(msg, now)) + } + } + } +} + +// sessToForeground updates perUser online status accounting and fires due +// deferred notifications for the provided session. +func (t *Topic) sessToForeground(sess *Session) { + s := sess + if s.multi != nil { + s = s.multi + } + + if pssd, ok := t.sessions[s]; ok && !pssd.isChanSub { + uid := pssd.uid + if s.isMultiplex() { + // If 's' is a multiplexing session, then sess is a proxy and it contains correct UID. + // Add UID to the list of online users. + uid = sess.uid + pssd.muids = append(pssd.muids, uid) + } + // Mark user as online + pud := t.perUser[uid] + pud.online++ + t.perUser[uid] = pud + + t.sendSubNotifications(uid, sess.sid, sess.userAgent) + } +} + +// Send immediate presence notification in response to a subscription. +// Send push notification to the P2P counterpart. +// In case of a new channel subscription subscribe user to an FCM topic. +// These notifications are always sent immediately even if background is requested. +func (t *Topic) sendImmediateSubNotifications(asUid types.Uid, acs *MsgAccessMode, sreg *ClientComMessage, now time.Time) { + modeWant, _ := types.ParseAcs([]byte(acs.Want)) + modeGiven, _ := types.ParseAcs([]byte(acs.Given)) + mode := modeWant & modeGiven + + asChan := t.isChan && types.IsChannel(sreg.Original) + + if t.cat == types.TopicCatP2P { + uid2 := t.p2pOtherUser(asUid) + pud2 := t.perUser[uid2] + mode2 := pud2.modeGiven & pud2.modeWant + if pud2.deleted { + mode2 = types.ModeInvalid + } + + // Inform the other user that the topic was just created. + if sreg.Sub.Created { + t.presSingleUserOffline(uid2, mode2, "acs", &presParams{ + dWant: pud2.modeWant.String(), + dGiven: pud2.modeGiven.String(), + actor: asUid.UserId(), + }, "", false) + } + + if sreg.Sub.Newsub { + // Notify current user's 'me' topic to accept notifications from user2 + t.presSingleUserOffline(asUid, mode, "?none+en", nilPresParams, "", false) + + // Initiate exchange of 'online' status with the other user. + // We don't know if the current user is online in the 'me' topic, + // so sending an '?unkn' status to user2. His 'me' topic + // will reply with user2's status and request an actual status from user1. + status := "?unkn" + if mode2.IsPresencer() { + // If user2 should receive notifications, enable it. + status += "+en" + } + t.presSingleUserOffline(uid2, mode2, status, nilPresParams, "", false) + + // Also send a push notification to the other user. + sendPush(t.pushForP2PSub(asUid, uid2, pud2.modeWant, pud2.modeGiven, now)) + } + } else if t.cat == types.TopicCatGrp && !asChan && sreg.Sub.Newsub { + // For new group subscriptions, notify other group members. + sendPush(t.pushForGroupSub(asUid, now)) + } + + // newsub could be true only for p2p and group topics, no need to check topic category explicitly. + if sreg.Sub.Newsub { + // Notify creator's other sessions that the subscription (or the entire topic) was created. + t.presSingleUserOffline(asUid, mode, "acs", + &presParams{ + dWant: acs.Want, + dGiven: acs.Given, + actor: asUid.UserId(), + }, + sreg.sess.sid, false) + + if asChan { + t.channelSubUnsub(asUid, true) + } + } +} + +// Send immediate or deferred presence notification in response to a subscription. +// Not used by channels. +func (t *Topic) sendSubNotifications(asUid types.Uid, sid, userAgent string) { + switch t.cat { + case types.TopicCatMe: + // Notify user's contact that the given user is online now. + if !t.isLoaded() { + t.markLoaded() + if err := t.loadContacts(asUid); err != nil { + logs.Err.Println("topic: failed to load contacts", t.name, err.Error()) + } + // User online: notify users of interest without forcing response (no +en here). + t.presUsersOfInterest("on", userAgent) + } + + case types.TopicCatGrp: + pud := t.perUser[asUid] + if pud.isChan { + // Not sendng notifications for channel readers. + return + } + + // Enable notifications for a new group topic, if appropriate. + if !t.isLoaded() { + t.markLoaded() + status := "on" + if (pud.modeGiven & pud.modeWant).IsPresencer() { + status += "+en" + } + + // Notify topic subscribers that the topic is online now. + t.presSubsOffline(status, nilPresParams, nilPresFilters, nilPresFilters, "", false) + } else if pud.online == 1 { + // If this is the first session of the user in the topic. + // Notify other online group members that the user is online now. + t.presSubsOnline("on", asUid.UserId(), nilPresParams, + &presFilters{filterIn: types.ModeRead}, sid) + } + } +} + +// Saves a new message (defined by head, content and attachments) in the topic +// in response to a client request (msg, asUid) and broadcasts it to the attached sessions. +func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, noEcho bool, attachments []string, head map[string]any, content any) error { + pud, userFound := t.perUser[asUid] + // Anyone is allowed to post to 'sys' topic. + if t.cat != types.TopicCatSys { + // If it's not 'sys' check write permission. + if !(pud.modeWant & pud.modeGiven).IsWriter() { + msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) + return types.ErrPermissionDenied + } + } + + if msg.sess != nil && msg.sess.uid != asUid { + // The "sender" header contains ID of the user who sent the message on behalf of asUid. + if head == nil { + head = map[string]any{} + } + head["sender"] = msg.sess.uid.UserId() + } else if head != nil { + // Make sure the received Head does not include a fake "sender" header. + delete(head, "sender") + } + + markedReadBySender := false + if err, unreadUpdated := store.Messages.Save(t.tenantID, + &types.Message{ + ObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp}, + TenantID: t.tenantID, + SeqId: t.lastID + 1, + Topic: t.name, + From: asUid.String(), + Head: head, + Content: content, + }, attachments, (pud.modeGiven & pud.modeWant).IsReader()); err != nil { + logs.Warn.Printf("topic[%s]: failed to save message: %v", t.name, err) + msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp)) + + return err + } else { + markedReadBySender = unreadUpdated + } + + t.lastID++ + t.touched = msg.Timestamp + + if userFound { + pud.readID = t.lastID + pud.recvID = t.lastID + t.perUser[asUid] = pud + } + + if msg.Id != "" && msg.sess != nil { + reply := NoErrAccepted(msg.Id, t.original(asUid), msg.Timestamp) + reply.Ctrl.Params = map[string]any{"seq": t.lastID} + msg.sess.queueOut(reply) + } + + data := &ServerComMessage{ + Data: &MsgServerData{ + Topic: msg.Original, + From: msg.AsUser, + Timestamp: msg.Timestamp, + SeqId: t.lastID, + Head: head, + Content: content, + }, + // Internal-only values. + Id: msg.Id, + RcptTo: msg.RcptTo, + AsUser: msg.AsUser, + Timestamp: msg.Timestamp, + sess: msg.sess, + } + if noEcho { + data.SkipSid = msg.sess.sid + } + + // Message sent: notify offline 'R' subscrbers on 'me'. + t.presSubsOffline("msg", &presParams{seqID: t.lastID, actor: msg.AsUser}, + &presFilters{filterIn: types.ModeRead}, nilPresFilters, "", true) + + // Tell the plugins that a message was accepted for delivery + pluginMessage(t.tenantID, data.Data, plgActCreate) + + t.broadcastToSessions(data) + + // sendPush will update unread message count and send push notification. + if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil { + sendPush(pushRcpt) + } + return nil +} + +// handlePubBroadcast fans out {pub} -> {data} messages to recipients in a master topic. +// This is a NON-proxy broadcast. +func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { + asUid := types.ParseUserId(msg.AsUser) + if t.isInactive() { + // Ignore broadcast - topic is paused or being deleted. + msg.sess.queueOut(ErrLocked(msg.Id, t.original(asUid), msg.Timestamp)) + return + } + + if t.isReadOnly() { + msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) + return + } + + isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil + if isCall { + if len(globals.iceServers) == 0 { + msg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow())) + return + } + if t.cat != types.TopicCatP2P { + msg.sess.queueOut(ErrPermissionDeniedReply(msg, types.TimeNow())) + return + } + if t.currentCall != nil { + msg.sess.queueOut(ErrCallBusyReply(msg, types.TimeNow())) + return + } + } + + // Save to DB at master topic. + var attachments []string + if msg.Extra != nil && len(msg.Extra.Attachments) > 0 { + attachments = msg.Extra.Attachments + } + + if err := t.saveAndBroadcastMessage(msg, asUid, msg.Pub.NoEcho, attachments, msg.Pub.Head, msg.Pub.Content); err != nil { + logs.Err.Printf("topic[%s]: failed to save messagge - %s", t.name, err) + return + } + + if isCall { + t.handleCallInvite(msg, asUid) + } +} + +// handleNoteBroadcast fans out {note} -> {info} messages to recipients in a master topic. +// This is a NON-proxy broadcast (at master topic). +func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) { + if t.isInactive() { + // Ignore broadcast - topic is paused or being deleted. + return + } + + if msg.Note.SeqId > t.lastID { + // Drop bogus read notification + return + } + + asChan, err := t.verifyChannelAccess(msg.Original) + if err != nil { + // Silently drop invalid notification. + return + } + + asUid := types.ParseUserId(msg.AsUser) + pud := t.perUser[asUid] + mode := pud.modeGiven & pud.modeWant + if pud.deleted { + mode = types.ModeInvalid + } + + switch msg.Note.What { + case "kp", "kpa", "kpv": + // Filter out "kp*" from users with no 'W' permission (or people without a subscription). + if !mode.IsWriter() || t.isReadOnly() { + return + } + case "read", "recv": + // Filter out "read/recv" from users with no 'R' permission (or people without a subscription). + if !mode.IsReader() { + return + } + case "call": + // Handle calls separately. + t.handleCallEvent(msg) + return + } + + var read, recv, unread, seq int + + switch msg.Note.What { + case "read": + if msg.Note.SeqId <= pud.readID { + // No need to report stale or bogus read status. + return + } + + // The number of unread messages has decreased, negative value. + unread = pud.readID - msg.Note.SeqId + pud.readID = msg.Note.SeqId + if pud.readID > pud.recvID { + pud.recvID = pud.readID + } + read = pud.readID + seq = read + case "recv": + if msg.Note.SeqId <= pud.recvID { + // Stale or bogus recv status. + return + } + + pud.recvID = msg.Note.SeqId + if pud.readID > pud.recvID { + pud.recvID = pud.readID + } + recv = pud.recvID + seq = recv + } + + if seq > 0 { + topicName := t.name + if asChan { + topicName = msg.Note.Topic + } + + upd := map[string]any{} + if recv > 0 { + upd["RecvSeqId"] = recv + } + if read > 0 { + upd["ReadSeqId"] = read + } + if err := store.Subs.Update(t.tenantID, topicName, asUid, upd); err != nil { + logs.Warn.Printf("topic[%s]: failed to update SeqRead/Recv counter: %v", t.name, err) + return + } + + // Read/recv updated: notify user's other sessions of the change + t.presPubMessageCount(asUid, mode, read, recv, msg.sess.sid) + + if read > 0 { + // Send push notification to other user devices. + sendPush(t.pushForReadRcpt(asUid, read, msg.Timestamp)) + } + + // Update cached count of unread messages (not tracking unread messages fror channels). + if !asChan { + usersUpdateUnread(t.tenantID, asUid, unread, true) + } + } + + if asChan { + // No need to forward {note} to other subscribers in channels + return + } + + if seq > 0 { + t.perUser[asUid] = pud + } + + // Read/recv/kp: notify users offline in the topic on their 'me'. + t.infoSubsOffline(asUid, msg.Note.What, seq, msg.sess.sid) + + info := &ServerComMessage{ + Info: &MsgServerInfo{ + Topic: msg.Original, + From: msg.AsUser, + What: msg.Note.What, + SeqId: msg.Note.SeqId, + }, + RcptTo: msg.RcptTo, + AsUser: msg.AsUser, + Timestamp: msg.Timestamp, + SkipSid: msg.sess.sid, + sess: msg.sess, + } + + t.broadcastToSessions(info) +} + +// handlePresence fans out {pres} messages to recipients in topic. +func (t *Topic) handlePresence(msg *ServerComMessage) { + what := t.procPresReq(msg.Pres.Src, msg.Pres.What, msg.Pres.WantReply) + if t.xoriginal != msg.Pres.Topic || what == "" { + // This is just a request for status, don't forward it to sessions + return + } + + // "what" may have changed, i.e. unset or "+command" removed ("on+en" -> "on") + msg.Pres.What = what + + t.broadcastToSessions(msg) +} + +// broadcastToSessions writes message to attached sessions. +func (t *Topic) broadcastToSessions(msg *ServerComMessage) { + msg.TenantID = t.tenantID + // List of sessions to be dropped. + var dropSessions []*Session + // Broadcast the message. Only {data}, {pres}, {info} are broadcastable. + // {meta} and {ctrl} are sent to the session only + for sess, pssd := range t.sessions { + // Send all messages to multiplexing session. + if !sess.isMultiplex() { + if sess.sid == msg.SkipSid { + continue + } + + if msg.Pres != nil { + // Skip notifying - already notified on topic. + if msg.Pres.SkipTopic != "" && sess.getSub(msg.Pres.SkipTopic) != nil { + continue + } + + // Notification addressed to a single user only. + if msg.Pres.SingleUser != "" && pssd.uid.UserId() != msg.Pres.SingleUser { + continue + } + // Notification should skip a single user. + if msg.Pres.ExcludeUser != "" && pssd.uid.UserId() == msg.Pres.ExcludeUser { + continue + } + + // Check presence filters + if !t.passesPresenceFilters(msg.Pres, pssd.uid) { + continue + } + + } else { + if msg.Info != nil { + // Don't forward read receipts and key presses to channel readers and those without the R permission. + // OK to forward with Src != "" because it's sent from another topic to 'me', permissions already + // checked there. + if msg.Info.Src == "" && (pssd.isChanSub || !t.userIsReader(pssd.uid)) { + continue + } + + // Skip notifying - already notified on topic. + if msg.Info.SkipTopic != "" && sess.getSub(msg.Info.SkipTopic) != nil { + continue + } + + // Don't send key presses from one user's session to the other sessions of the same user. + if msg.Info.What == "kp" && msg.Info.From == pssd.uid.UserId() { + continue + } + + } else if !t.userIsReader(pssd.uid) && !pssd.isChanSub { + // Skip {data} if the user has no Read permission and not a channel reader. + continue + } + } + } else if pssd.isChanSub && types.IsChannel(sess.sid) { + // If it's a chnX multiplexing session, check if there's a corresponding + // grpX multiplexing session as we don't want to send the message to both. + grpSid := types.ChnToGrp(sess.sid) + if grpSess := globals.sessionStore.Get(grpSid); grpSess != nil && grpSess.isMultiplex() { + // If grpX multiplexing session's attached to topic, skip this chnX session + // (message will be routed to the topic proxy via the grpX session). + if _, attached := t.sessions[grpSess]; attached { + continue + } + } + } + + // Make a copy of msg since messages sent to sessions differ. + msgCopy := msg.copy() + // Topic name may be different depending on the user to which the `sess` belongs. + t.prepareBroadcastableMessage(msgCopy, pssd.uid, pssd.isChanSub) + // Send message to session. + if !sess.queueOut(msgCopy) { + logs.Warn.Printf("topic[%s]: connection stuck, detaching - %s", t.name, sess.sid) + dropSessions = append(dropSessions, sess) + } + } + + // Drop "bad" sessions. + for _, sess := range dropSessions { + // The whole session is being dropped, so ClientComMessage.init is false. + // keep redundant init: false so it can be searched for. + t.unregisterSession(&ClientComMessage{sess: sess, init: false}) + } +} + +// subscriptionReply generates a response to a subscription request +func (t *Topic) subscriptionReply(asChan bool, msg *ClientComMessage) error { + // The topic is already initialized by the Hub + + msgsub := msg.Sub + + // For newly created topics report topic creation time. + var now time.Time + if msgsub.Created { + now = t.updated + } else { + now = types.TimeNow() + } + + asUid := types.ParseUserId(msg.AsUser) + + if !msgsub.Newsub && (t.cat == types.TopicCatP2P || t.cat == types.TopicCatGrp) { + // Check if this is a new subscription (P2P & GRP only. SLF, SYS are excluded here). + pud, found := t.perUser[asUid] + msgsub.Newsub = !found || pud.deleted + } + + var private any + var mode string + if msgsub.Set != nil { + if msgsub.Set.Sub != nil { + if msgsub.Set.Sub.User != "" { + msg.sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("user id must not be specified") + } + mode = msgsub.Set.Sub.Mode + } + + if msgsub.Set.Desc != nil { + private = msgsub.Set.Desc.Private + } + } + + var err error + var modeChanged *MsgAccessMode + // Create new subscription or modify an existing one. + if modeChanged, err = t.thisUserSub(msg.sess, msg, asUid, asChan, mode, private); err != nil { + return err + } + + hasJoined := true + if modeChanged != nil { + if acs, err := types.ParseAcs([]byte(modeChanged.Mode)); err == nil { + hasJoined = acs.IsJoiner() + } + } + + if hasJoined { + // Subscription successfully created. Link topic to session. + msg.sess.addSub(t.name, &Subscription{ + broadcast: t.clientMsg, + done: t.unreg, + meta: t.meta, + supd: t.supd, + }) + t.addSession(msg.sess, asUid, asChan) + + // The user is online in the topic. Increment the counter if notifications are not deferred. + if !msg.sess.background { + userData := t.perUser[asUid] + userData.online++ + t.perUser[asUid] = userData + } + + if t.cat == types.TopicCatGrp && msgsub.Newsub { + // Increment subscriber count for new group subscriptions only. + t.subCnt++ + } + } + + params := map[string]any{} + // Report back the assigned access mode. + if modeChanged != nil { + params["acs"] = modeChanged + } + toriginal := t.original(asUid) + + // When a group topic is created, it's given a temporary name by the client. + // Then this name changes. Report back the original name here. + if msgsub.Created && msg.Original != toriginal { + params["tmpname"] = msg.Original + // The new123ABC name is no longer useful after this. + msg.Original = toriginal + } + + if len(params) == 0 { + // Don't send empty params '{}' + msg.sess.queueOut(NoErr(msg.Id, toriginal, now)) + } else { + msg.sess.queueOut(NoErrParams(msg.Id, toriginal, now, params)) + } + + // Some notifications are always sent immediately. + if modeChanged != nil { + t.sendImmediateSubNotifications(asUid, modeChanged, msg, now) + } + + if !msg.sess.background && hasJoined { + // Other notifications are also sent immediately for foreground sessions. + t.sendSubNotifications(asUid, msg.sess.sid, msg.sess.userAgent) + } + + return nil +} + +// User requests or updates a self-subscription to a topic. Called as a +// result of {sub} or {meta set=sub}. +// Returns new access mode as *MsgAccessMode if user's access mode has changed, nil otherwise. +// +// sess - originating session +// pkt - client message which triggered this request; {sub} or {set} +// asUid - id of the user making the request +// asChan - true if the user is subscribing to a channel topic +// want - requested access mode +// private - private value to assign to the subscription +// background - presence notifications are deferred +// +// Handle these cases: +// A. User is trying to subscribe for the first time (no subscription). +// A.1 Normal user is subscribing to the topic. +// A.2 Reader is joining the channel. +// B. User is already subscribed, just joining without changing anything. +// C. User is responding to an earlier invite (modeWant was "N" in subscription). +// D. User is already subscribed, changing modeWant. +// E. User is accepting ownership transfer (requesting ownership transfer is not permitted). +// In case of a group topic the user may be a reader or a full subscriber. +func (t *Topic) thisUserSub(sess *Session, pkt *ClientComMessage, asUid types.Uid, asChan bool, want string, + private any) (*MsgAccessMode, error) { + + now := types.TimeNow() + asLvl := auth.Level(pkt.AuthLvl) + + // Access mode values as they were before this request was processed. + oldWant := types.ModeNone + oldGiven := types.ModeNone + + // Parse access mode requested by the user + modeWant := types.ModeUnset + if want != "" { + if err := modeWant.UnmarshalText([]byte(want)); err != nil { + sess.queueOut(ErrMalformedReply(pkt, now)) + return nil, err + } + } + + var err error + // Check if it's an attempt at a new subscription to the topic / a first connection of a channel reader + // (channel readers are not permanently cached). + // It could be an actual subscription (IsJoiner() == true) or a ban (IsJoiner() == false). + userData, existingSub := t.perUser[asUid] + if !existingSub || userData.deleted { + // New subscription or a not yet cached channel reader, either new or existing. + + // Check if the max number of subscriptions is already reached. + if t.cat == types.TopicCatGrp && !asChan && t.subsCount() >= globals.maxSubscriberCount { + sess.queueOut(ErrPolicyReply(pkt, now)) + return nil, errors.New("max subscription count exceeded") + } + + var sub *types.Subscription + tname := t.name + if t.cat == types.TopicCatP2P { + // P2P could be here only if it was previously deleted. I.e. existingSub is always true for P2P. + if modeWant != types.ModeUnset { + userData.modeWant = modeWant + } + // If no modeWant is provided, leave existing one unchanged. + + // Make sure the user is not asking for unreasonable permissions + userData.modeWant = (userData.modeWant & globals.typesModeCP2P) | types.ModeApprove + } else if t.cat == types.TopicCatSys { + if asLvl != auth.LevelRoot { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("subscription to 'sys' topic requires root access level") + } + + // Assign default access levels + userData.modeWant = types.ModeCSys + userData.modeGiven = types.ModeCSys + if modeWant != types.ModeUnset { + userData.modeWant = (modeWant & types.ModeCSys) | types.ModeWrite | types.ModeJoin + } + } else if asChan { + userData.isChan = true + + // Check if user is already subscribed. + sub, err = store.Subs.Get(t.tenantID, pkt.Original, asUid, false) + if err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + + // Given mode is immutable. + oldGiven = types.ModeCChnReader + userData.modeGiven = types.ModeCChnReader + + if sub != nil { + // Subscription exists, read old access mode. + oldWant = sub.ModeWant + } else { + // Subscription not found, use default. + oldWant = types.ModeCChnReader + } + + if modeWant != types.ModeUnset { + // New access mode is explicitly assigned. + userData.modeWant = (modeWant & types.ModeCChnReader) | types.ModeRead | types.ModeJoin + } else { + // Default: unchanged. + userData.modeWant = oldWant + } + + // User is subscribed to chnXXX, not grpXXX. + tname = pkt.Original + } else { + // All other topic types. + + if !existingSub { + + // Check if the user has been subscribed previously and if so, use previous modeGiven. + // Otherwise the user may delete subscription and resubscribe to avoid being blocked. + sub, err = store.Subs.Get(t.tenantID, t.name, asUid, true) + if err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + + if sub != nil { + userData.modeGiven = sub.ModeGiven + } else { + // If no mode was previously given, give default access. + userData.modeGiven = types.ModeUnset + } + } + + if userData.modeGiven == types.ModeUnset { + // New user: default access. + userData.modeGiven = t.accessFor(asLvl) + } + + if modeWant == types.ModeUnset { + // User wants default access mode. + userData.modeWant = t.accessFor(asLvl) + } else { + userData.modeWant = modeWant + } + } + + // Reject new subscription: 'given' permissions have no 'J'. + if !userData.modeGiven.IsJoiner() { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("subscription rejected due to permissions") + } + + // Undelete. + if userData.deleted { + userData.deleted = false + userData.delID, userData.readID, userData.recvID = 0, 0, 0 + } + + if isNullValue(private) { + private = nil + } + userData.private = private + + // Add subscription to database, if missing. + if sub == nil || sub.DeletedAt != nil { + sub = &types.Subscription{ + User: asUid.String(), + Topic: tname, + ModeWant: userData.modeWant, + ModeGiven: userData.modeGiven, + Private: userData.private, + } + + if sub.TenantID.IsZero() { + sub.TenantID = t.tenantID + } + if err := store.Subs.Create(t.tenantID, sub); err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + + } else if asChan && userData.modeWant != oldWant { + // Channel reader changed access mode, save changed mode to db. + if err := store.Subs.Update(t.tenantID, tname, asUid, + map[string]any{"ModeWant": userData.modeWant}); err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + + // Enable or disable fcm push notifications for the subsciption. + t.channelSubUnsub(asUid, userData.modeWant.IsPresencer()) + } + + if asChan { + if userData.modeWant != oldWant { + pluginSubscription(sub, plgActCreate) + } else { + pluginSubscription(sub, plgActUpd) + } + } else { + // Add subscribed user to cache. + usersRegisterUser(t.tenantID, asUid, true) + // Notify plugins of a new subscription + pluginSubscription(sub, plgActCreate) + } + + } else { + // Process update to existing subscription. It could be an incomplete subscription for a new topic. + if !userData.isChan && asChan { + // A normal subscriber is trying to access topic as a channel. + // Direct the subscriber to use non-channel topic name. + sess.queueOut(InfoUseOtherReply(pkt, t.name, now)) + return nil, types.ErrNotFound + } + + var ownerChange bool + + // Save old access values + + oldWant = userData.modeWant + oldGiven = userData.modeGiven + + if modeWant != types.ModeUnset { + // Explicit modeWant is provided + + // Make sure the current owner cannot unset the owner flag or ban himself. + if t.owner == asUid && (!modeWant.IsOwner() || !modeWant.IsJoiner()) { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("cannot unset ownership or self-ban the owner") + } + + // Perform sanity checks + if userData.modeGiven.IsOwner() { + // Check for possible ownership transfer. Handle the following cases: + // 1. Acceptance or rejection of the ownership transfer + // 2. Owner changing own settings + + // Ownership transfer + ownerChange = modeWant.IsOwner() && !userData.modeWant.IsOwner() + + // The owner should be able to grant himself any access permissions. + if modeWant.IsOwner() && !userData.modeGiven.BetterEqual(modeWant) { + userData.modeGiven |= modeWant + } + } else if modeWant.IsOwner() { + // Ownership transfer can only be initiated by the owner. + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("non-owner cannot request ownership transfer") + } else if t.cat == types.TopicCatGrp && userData.modeGiven.IsAdmin() && modeWant.IsAdmin() { + // A group topic Admin should be able to grant himself any permissions except + // ownership (checked previously) & hard-deleting messages. + if !userData.modeGiven.BetterEqual(modeWant & ^types.ModeDelete) { + userData.modeGiven |= (modeWant & ^types.ModeDelete) + } + } + + switch t.cat { + case types.TopicCatP2P: + // For P2P topics ignore requests exceeding the maximum allowed. Otherwise it will generate + // a useless announcement. + modeWant = (modeWant & globals.typesModeCP2P) | types.ModeApprove + case types.TopicCatSys: + // Anyone can always write to Sys topic. + modeWant &= (modeWant & types.ModeCSys) | types.ModeWrite + } + } + + // If user has not requested a new access mode, provide one by default. + if modeWant == types.ModeUnset { + // If the user has self-banned before, un-self-ban. Otherwise do not make a change. + if !oldWant.IsJoiner() { + // Set permissions NO WORSE than default, but possibly better (admin or owner banned himself). + userData.modeWant = userData.modeGiven | t.accessFor(asLvl) + } + } else if userData.modeWant != modeWant { + // The user has provided a new modeWant and it' different from the one before + userData.modeWant = modeWant + } + + // Create a subscription object to notify plugins. + sub := types.Subscription{ + User: asUid.String(), + Topic: t.name, + } + + // Save changes to DB + update := map[string]any{} + if isNullValue(private) { + update["Private"] = nil + userData.private = nil + sub.Private = private + } else if private != nil { + update["Private"] = private + userData.private = private + sub.Private = private + } + if userData.modeWant != oldWant { + update["ModeWant"] = userData.modeWant + sub.ModeWant = userData.modeWant + } + if userData.modeGiven != oldGiven { + update["ModeGiven"] = userData.modeGiven + sub.ModeGiven = userData.modeGiven + } + + if len(update) > 0 { + if err := store.Subs.Update(t.tenantID, t.name, asUid, update); err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + pluginSubscription(&sub, plgActUpd) + } + + // No transactions in RethinkDB, but two owners are better than none + if ownerChange { + oldOwnerData := t.perUser[t.owner] + oldOwnerOldWant, oldOwnerOldGiven := oldOwnerData.modeWant, oldOwnerData.modeGiven + oldOwnerData.modeGiven = (oldOwnerData.modeGiven & ^types.ModeOwner) + oldOwnerData.modeWant = (oldOwnerData.modeWant & ^types.ModeOwner) + if err := store.Subs.Update(t.tenantID, t.name, t.owner, + map[string]any{ + "ModeWant": oldOwnerData.modeWant, + "ModeGiven": oldOwnerData.modeGiven, + }); err != nil { + return nil, err + } + if err := store.Topics.OwnerChange(t.tenantID, t.name, asUid); err != nil { + return nil, err + } + t.perUser[t.owner] = oldOwnerData + // Send presence notifications. + t.notifySubChange(t.owner, asUid, false, + oldOwnerOldWant, oldOwnerOldGiven, oldOwnerData.modeWant, oldOwnerData.modeGiven, "") + t.owner = asUid + } + } + + if !asChan { + // If topic is being muted, send "off" notification and disable updates. + // Do it before applying the new permissions. + if (oldWant & oldGiven).IsPresencer() && !(userData.modeWant & userData.modeGiven).IsPresencer() { + if t.cat == types.TopicCatMe { + t.presUsersOfInterest("off+dis", t.userAgent) + } else { + t.presSingleUserOffline(asUid, userData.modeWant&userData.modeGiven, + "off+dis", nilPresParams, "", false) + } + } + } + // Apply changes. + t.perUser[asUid] = userData + + var modeChanged *MsgAccessMode + // Send presence notifications and update cached unread count. + if oldWant != userData.modeWant || oldGiven != userData.modeGiven { + if !asChan { + oldReader := (oldWant & oldGiven).IsReader() + newReader := (userData.modeWant & userData.modeGiven).IsReader() + + if oldReader && !newReader { + // Decrement unread count + usersUpdateUnread(t.tenantID, asUid, userData.readID-t.lastID, true) + } else if !oldReader && newReader { + // Increment unread count + usersUpdateUnread(t.tenantID, asUid, t.lastID-userData.readID, true) + } + } + + // Notify actor of the changes in access mode. + t.notifySubChange(asUid, asUid, asChan, oldWant, oldGiven, userData.modeWant, userData.modeGiven, sess.sid) + } + + if (pkt.Sub != nil && pkt.Sub.Newsub) || oldWant != userData.modeWant || oldGiven != userData.modeGiven { + modeChanged = &MsgAccessMode{ + Want: userData.modeWant.String(), + Given: userData.modeGiven.String(), + Mode: (userData.modeGiven & userData.modeWant).String(), + } + } + + if !userData.modeWant.IsJoiner() { + // The user is self-banning from the topic. Re-subscription will unban. + t.evictUser(asUid, false, "") + // The callee will send NoErrOK + return modeChanged, nil + } + + if !userData.modeGiven.IsJoiner() { + // User was banned + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("topic access denied; user is banned") + } + + return modeChanged, nil +} + +// anotherUserSub processes a request to initiate an invite or approve a subscription request from another user. +// Returns changed == true if user's access mode has changed. +// Handle these cases: +// A. Sharer or Approver is inviting another user for the first time (no prior subscription) +// B. Sharer or Approver is re-inviting another user (adjusting modeGiven, modeWant is still Unset) +// C. Approver is changing modeGiven for another user, modeWant != Unset +func (t *Topic) anotherUserSub(sess *Session, asUid, target types.Uid, asChan bool, + pkt *ClientComMessage) (*MsgAccessMode, error) { + + now := types.TimeNow() + set := pkt.Set + + // Check if approver actually has permission to manage sharing + hostData, ok := t.perUser[asUid] + // Access mode of the person who is executing this approval process + hostMode := hostData.modeGiven & hostData.modeWant + if !ok || !hostMode.IsSharer() { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("topic access denied; approver has no permission") + } + + if asChan { + // TODO: need to implement promoting reader to subscriber. Rejecting for now. + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("topic access denied: cannot subscribe reader to channel") + } + + // Check if topic is suspended. + if t.isReadOnly() { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("topic is suspended") + } + + // Parse the access mode granted + modeGiven := types.ModeUnset + if set.Sub.Mode != "" { + if err := modeGiven.UnmarshalText([]byte(set.Sub.Mode)); err != nil { + sess.queueOut(ErrMalformedReply(pkt, now)) + return nil, err + } + + // Make sure the new permissions are reasonable in P2P topics: permissions no greater than allowed, + // approver permission cannot be removed. + if t.cat == types.TopicCatP2P { + modeGiven = (modeGiven & globals.typesModeCP2P) | types.ModeApprove + } + } + + // Make sure only the owner & approvers can set non-default access mode + if modeGiven != types.ModeUnset && !hostMode.IsAdmin() { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("sharer cannot set explicit modeGiven") + } + + // Make sure no one but the owner can do an ownership transfer + if modeGiven.IsOwner() && t.owner != asUid { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("attempt to transfer ownership by non-owner") + } + + // Access mode values as they were before this request was processed. + oldWant := types.ModeUnset + oldGiven := types.ModeUnset + + // Check if it's a new invite. If so, save it to database as a subscription. + // Saved subscription does not mean the user is allowed to post/read + userData, existingSub := t.perUser[target] + if !existingSub || userData.deleted { + // Check if the max number of subscriptions is already reached. + if t.cat == types.TopicCatGrp && t.subsCount() >= globals.maxSubscriberCount { + sess.queueOut(ErrPolicyReply(pkt, now)) + return nil, errors.New("max subscription count exceeded") + } + + if modeGiven == types.ModeUnset { + // Request to use default access mode for the new subscriptions. + // Assuming LevelAuth. Approver should use non-default access if that is not suitable. + modeGiven = t.accessFor(auth.LevelAuth) + // Enable new subscription even if default is no joiner. + modeGiven |= types.ModeJoin + } + + var modeWant types.AccessMode + // Check if the invitee has been subscribed previously and if so, use previous modeWant. + // Otherwise the inviter may delete blocked subscription and reinvite to spam the user. + sub, err := store.Subs.Get(t.tenantID, t.name, target, true) + if err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + + if sub != nil { + // Existing deleted subscription. + modeWant = sub.ModeWant + } else { + // Get user's default access mode to be used as modeWant + if user, err := store.Users.Get(t.tenantID, target); err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } else if user == nil { + sess.queueOut(ErrUserNotFoundReply(pkt, now)) + return nil, errors.New("user not found") + } else if user.State != types.StateOK { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("user is suspended") + } else { + // Don't ask by default for more permissions than the granted ones. + modeWant = user.Access.Auth & modeGiven + } + } + + // Reject invitation: 'want' permissions have no 'J'. + if !modeWant.IsJoiner() { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("invitation rejected due to permissions") + } + + // Add subscription to database + sub = &types.Subscription{ + User: target.String(), + Topic: t.name, + ModeWant: modeWant, + ModeGiven: modeGiven, + } + + if sub.TenantID.IsZero() { + sub.TenantID = t.tenantID + } + if err := store.Subs.Create(t.tenantID, sub); err != nil { + sess.queueOut(ErrUnknownReply(pkt, now)) + return nil, err + } + + userData = perUserData{ + modeGiven: sub.ModeGiven, + modeWant: sub.ModeWant, + private: nil, + } + t.perUser[target] = userData + t.computePerUserAcsUnion() + + // Cache user's record + usersRegisterUser(t.tenantID, target, true) + + // Notify plugins of a new subscription. + pluginSubscription(sub, plgActCreate) + + // Send push notification for the new subscription. + // TODO: maybe skip user's devices which were online when this event has happened. + sendPush(t.pushForP2PSub(asUid, target, userData.modeWant, userData.modeGiven, now)) + } else { + // Action on an existing subscription: re-invite, change existing permission, confirm/decline request. + oldGiven = userData.modeGiven + oldWant = userData.modeWant + + if modeGiven == types.ModeUnset { + // Request to re-send invite without changing the access mode + modeGiven = userData.modeGiven + } else if modeGiven != userData.modeGiven { + // Changing the previously assigned value. + + // Cannot strip owner of ownership or ban the owner. + if t.owner == target && (!modeGiven.IsOwner() || !modeGiven.IsJoiner()) { + sess.queueOut(ErrPermissionDeniedReply(pkt, now)) + return nil, errors.New("cannot stip ownership or ban the owner") + } + + // Save changed value to database + if err := store.Subs.Update(t.tenantID, t.name, target, + map[string]any{"ModeGiven": modeGiven}); err != nil { + return nil, err + } + + userData.modeGiven = modeGiven + t.perUser[target] = userData + } + } + + var modeChanged *MsgAccessMode + // Access mode has changed. + if oldGiven != userData.modeGiven { + + oldReader := (oldWant & oldGiven).IsReader() + newReader := (userData.modeWant & userData.modeGiven).IsReader() + if oldReader && !newReader { + // Decrement unread count + usersUpdateUnread(t.tenantID, target, userData.readID-t.lastID, true) + } else if !oldReader && newReader { + // Increment unread count + usersUpdateUnread(t.tenantID, target, t.lastID-userData.readID, true) + } + t.notifySubChange(target, asUid, false, + oldWant, oldGiven, userData.modeWant, userData.modeGiven, sess.sid) + + modeChanged = &MsgAccessMode{ + Given: userData.modeGiven.String(), + Want: userData.modeWant.String(), + Mode: (userData.modeGiven & userData.modeWant).String(), + } + } + + if !userData.modeGiven.IsJoiner() { + // The user is banned from the topic. + t.evictUser(target, false, "") + } + + return modeChanged, nil +} + +// replyGetDesc is a response to a get.desc request on a topic, sent to just the session as a {meta} packet +func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGetOpts, msg *ClientComMessage) error { + now := types.TimeNow() + id := msg.Id + + if opts != nil && (opts.User != "" || opts.Limit != 0) { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("invalid GetDesc query") + } + + // Check if user requested modified data + ifUpdated := opts == nil || opts.IfModifiedSince == nil || opts.IfModifiedSince.Before(t.updated) + + desc := &MsgTopicDesc{} + if opts == nil || opts.IfModifiedSince == nil { + // Send CreatedAt only when the user requests full information (nothing is cached at the client). + desc.CreatedAt = &t.created + } + if !t.updated.IsZero() { + desc.UpdatedAt = &t.updated + } + + pud, full := t.perUser[asUid] + + full = full || t.cat == types.TopicCatMe + + if t.cat == types.TopicCatGrp { + desc.IsChan = t.isChan + desc.SubCnt = t.subCnt + logs.Info.Println("replyGetDesc: grp topic", t.name, "subs", t.subCnt) + } + + if ifUpdated { + if t.public != nil || t.trusted != nil { + // Not a p2p topic. + desc.Public = t.public + desc.Trusted = t.trusted + } else if full && t.cat == types.TopicCatP2P { + // FIXME: when a P2P participant updates desc at 'me', these cached values are not updated. + desc.Public = pud.public + desc.Trusted = pud.trusted + } + } + + // Request may come from a subscriber (full == true) or a stranger. + // Give subscriber a fuller description than to a stranger/channel reader. + if full { + if t.cat == types.TopicCatP2P { + // For p2p topics default access mode makes no sense: only participants have access to topic. + // Don't report it. + } else if t.cat == types.TopicCatMe || (pud.modeGiven & pud.modeWant).IsSharer() { + desc.DefaultAcs = &MsgDefaultAcsMode{ + Auth: t.accessAuth.String(), + Anon: t.accessAnon.String(), + } + } + + desc.Acs = &MsgAccessMode{ + Want: pud.modeWant.String(), + Given: pud.modeGiven.String(), + Mode: (pud.modeGiven & pud.modeWant).String(), + } + + if t.cat == types.TopicCatMe && sess.authLvl == auth.LevelRoot { + // If 'me' is in memory then user account is invariably not suspended. + desc.State = types.StateOK.String() + } + + if (pud.modeGiven & pud.modeWant).IsPresencer() { + switch t.cat { + case types.TopicCatGrp: + desc.Online = t.isOnline() + case types.TopicCatP2P: + // This is the timestamp when the other user logged off last time. + // It does not change while the topic is loaded into memory and that's OK most of the time + // because to stay in memory at least one of the users must be connected to topic. + // FIXME(gene): it breaks when user A stays active in one session and connects-disconnects + // from another session. The second session will not see correct LastSeen time and UserAgent. + if pud.lastSeen != nil { + desc.LastSeen = &MsgLastSeenInfo{ + When: pud.lastSeen, + UserAgent: pud.lastUA, + } + } + } + } + + if ifUpdated { + desc.Private = pud.private + } + + // Don't report message IDs to users without Read access. + if (pud.modeGiven & pud.modeWant).IsReader() { + desc.SeqId = t.lastID + if !t.touched.IsZero() { + desc.TouchedAt = &t.touched + } + + // Make sure reported values are sane: + // t.delID <= pud.delID; t.readID <= t.recvID <= t.lastID + desc.DelId = max(pud.delID, t.delID) + desc.ReadSeqId = pud.readID + desc.RecvSeqId = max(pud.recvID, pud.readID) + } else { + // Send some sane value of touched. + desc.TouchedAt = &t.updated + } + } + + sess.queueOut(&ServerComMessage{ + Meta: &MsgServerMeta{ + Id: id, + Topic: msg.Original, + Desc: desc, + Timestamp: &now, + }, + }) + + return nil +} + +// replySetDesc updates topic metadata, saves it to DB, replies to the caller as {ctrl} message, +// generates {pres} update if necessary. +func (t *Topic) replySetDesc(sess *Session, asUid types.Uid, asChan bool, + authLevel auth.Level, msg *ClientComMessage) error { + now := types.TimeNow() + + assignAccess := func(upd map[string]any, mode *MsgDefaultAcsMode) error { + if mode == nil { + return nil + } + if auth, anon, err := parseTopicAccess(mode, types.ModeUnset, types.ModeUnset); err != nil { + return err + } else if auth.IsOwner() || anon.IsOwner() { + return errors.New("default 'owner' access is not permitted") + } else { + access := types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon} + if auth != types.ModeUnset { + if t.cat == types.TopicCatMe { + auth &= types.ModeCAuth + if auth != types.ModeNone { + // This is the default access mode for P2P topics. + // It must be either an N or must include an A permission. + auth |= types.ModeApprove + } + } + access.Auth = auth + } + if anon != types.ModeUnset { + if t.cat == types.TopicCatMe { + anon &= globals.typesModeCP2P + if anon != types.ModeNone { + anon |= types.ModeApprove + } + } + access.Anon = anon + } + if access.Auth != t.accessAuth || access.Anon != t.accessAnon { + upd["Access"] = access + } + } + return nil + } + + assignGenericValues := func(upd map[string]any, what string, dst, src any) (changed bool) { + if dst, changed = mergeInterfaces(dst, src); changed { + upd[what] = dst + } + return + } + + // DefaultAccess and/or Public have chanegd + var sendCommon bool + // Private has changed + var sendPriv bool + var err error + + // Change to the main object (user or topic). + core := make(map[string]any) + // Change to subscription. + sub := make(map[string]any) + if set := msg.Set; set.Desc != nil { + if set.Desc.Trusted != nil && authLevel != auth.LevelRoot { + // Only ROOT can change Trusted. + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("attempt to change Trusted by non-root") + } + + switch t.cat { + case types.TopicCatMe: + // Update current user + err = assignAccess(core, set.Desc.DefaultAcs) + sendCommon = assignGenericValues(core, "Public", t.public, set.Desc.Public) + sendCommon = assignGenericValues(core, "Trusted", t.trusted, set.Desc.Trusted) || sendCommon + case types.TopicCatFnd: + // set.Desc.DefaultAcs is ignored. + if set.Desc.Trusted != nil { + // 'fnd' does not support Trusted. + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("attempt to assign Trusted in fnd topic") + } + // Do not send presence if fnd.Public has changed. + assignGenericValues(core, "Public", t.fndGetPublic(sess), set.Desc.Public) + case types.TopicCatP2P: + // Reject direct changes to P2P topics. + if set.Desc.Public != nil || set.Desc.Trusted != nil || set.Desc.DefaultAcs != nil { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("incorrect attempt to change metadata of a p2p topic") + } + case types.TopicCatGrp: + // Update group topic + if t.owner == asUid { + err = assignAccess(core, set.Desc.DefaultAcs) + sendCommon = assignGenericValues(core, "Public", t.public, set.Desc.Public) + sendCommon = assignGenericValues(core, "Trusted", t.trusted, set.Desc.Trusted) || sendCommon + } else if set.Desc.DefaultAcs != nil || set.Desc.Public != nil || set.Desc.Trusted != nil { + // This is a request from non-owner + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("attempt to change public or permissions by non-owner") + } + } + + if err != nil { + sess.queueOut(ErrMalformedReply(msg, now)) + return err + } + + sendPriv = assignGenericValues(sub, "Private", t.perUser[asUid].private, set.Desc.Private) + } + + if len(core)+len(sub) == 0 { + sess.queueOut(InfoNotModifiedReply(msg, now)) + return errors.New("{set} generated no update to DB") + } + + if len(core) > 0 { + core["UpdatedAt"] = now + switch t.cat { + case types.TopicCatMe: + err = store.Users.Update(t.tenantID, asUid, core) + case types.TopicCatFnd: + // The only value to be stored in topic is Public, and Public for fnd is not saved according to specs. + default: + err = store.Topics.Update(t.tenantID, t.name, core) + } + } + if err == nil && len(sub) > 0 { + tname := t.name + if asChan { + tname = types.GrpToChn(tname) + } + err = store.Subs.Update(t.tenantID, tname, asUid, sub) + } + + if err != nil { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + + if len(core) > 0 && msg.Extra != nil && len(msg.Extra.Attachments) > 0 { + if err := store.Files.LinkAttachments(t.tenantID, t.name, types.ZeroUid, msg.Extra.Attachments); err != nil { + logs.Warn.Printf("topic[%s] failed to link avatar attachment: %v", t.name, err) + // This is not a critical error, continue execution. + } + } + + // Update values cached in the topic object + switch t.cat { + case types.TopicCatMe, types.TopicCatGrp: + if tmp, ok := core["Access"]; ok { + access := tmp.(types.DefaultAccess) + t.accessAuth = access.Auth + t.accessAnon = access.Anon + } + if public, ok := core["Public"]; ok { + t.public = public + } + if trusted, ok := core["Trusted"]; ok { + t.trusted = trusted + } + case types.TopicCatFnd: + // Assign per-session fnd.Public. + t.fndSetPublic(sess, core["Public"]) + } + + pud := t.perUser[asUid] + mode := pud.modeGiven & pud.modeWant + if private, ok := sub["Private"]; ok { + pud.private = private + t.perUser[asUid] = pud + } + + if sendCommon || sendPriv { + // t.public/t.trusted, t.accessAuth/Anon have changed, make an announcement + if sendCommon { + if t.cat == types.TopicCatMe { + t.presUsersOfInterest("upd", "") + } else { + // Notify all subscribers on 'me' except the user who made the change and blocked users. + // The user who made the change will be notified separately (see below). + filter := &presFilters{excludeUser: asUid.UserId(), filterIn: types.ModeJoin} + t.presSubsOffline("upd", nilPresParams, filter, filter, sess.sid, false) + } + + t.updated = now + } + // Notify user's other sessions. + t.presSingleUserOffline(asUid, mode, "upd", nilPresParams, sess.sid, false) + } + + sess.queueOut(NoErrReply(msg, now)) + + return nil +} + +// replyGetSub is a response to a get.sub request on a topic - load a list of subscriptions/subscribers, +// send it just to the session as a {meta} packet +func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level, asChan bool, msg *ClientComMessage) error { + now := types.TimeNow() + id := msg.Id + incomingReqTs := msg.Timestamp + var req *MsgGetOpts + if msg.Sub != nil { + req = msg.Sub.Get.Sub + } else { + req = msg.Get.Sub + } + + if req != nil && (req.SinceId != 0 || req.BeforeId != 0) { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("invalid MsgGetOpts query") + } + + var err error + + var ifModified time.Time + if req != nil && req.IfModifiedSince != nil { + ifModified = *req.IfModifiedSince + } + + userData := t.perUser[asUid] + var subs []types.Subscription + + switch t.cat { + case types.TopicCatMe: + if req != nil { + // If topic is provided, it could be in the form of user ID 'usrAbCd'. + // Convert it to P2P topic name. Likewise for Self topic 'slf' -> 'slfAbcD'. + if uid2 := types.ParseUserId(req.Topic); !uid2.IsZero() { + req.Topic = uid2.P2PName(asUid) + } + if req.Topic == "slf" { + req.Topic = asUid.SlfName() + } + } + // Fetch user's subscriptions, with Topic.Public+Topic.Trusted denormalized into subscription. + if ifModified.IsZero() { + // No cache management. Skip deleted subscriptions. + subs, err = store.Users.GetTopics(t.tenantID, asUid, msgOpts2storeOpts(req)) + } else { + // User manages cache. Include deleted subscriptions too. + subs, err = store.Users.GetTopicsAny(t.tenantID, asUid, msgOpts2storeOpts(req)) + + // Returned subscriptions do not contain topics which are online now but otherwise unchanged. + // We need to add these topic to the list otherwise the user would see them as offline. + selected := map[string]struct{}{} + for i := range subs { + sub := &subs[i] + with := sub.GetWith() + if with != "" { + selected[with] = struct{}{} + } else { + selected[sub.Topic] = struct{}{} + } + } + + // Add dummy subscriptions for missing online topics. + for topic, psd := range t.perSubs { + _, present := selected[topic] + if !present && psd.online { + sub := types.Subscription{Topic: topic} + sub.SetWith(topic) + sub.SetDummy(true) + subs = append(subs, sub) + } + } + } + case types.TopicCatFnd: + // Select public or private query. Public is set interactively and has priority. + query := t.fndGetPublic(sess) + if query == "" { + query, _ = userData.private.(string) + } + + // Empty queries are ignored with "NoContent". + if query != "" { + query, subs, err = pluginFind(t.tenantID, asUid, query) + if err == nil && subs == nil && query != "" { + if and, opt, err := parseSearchQuery(query); err == nil { + var req [][]string + for _, tag := range and { + rewritten := rewriteTag(tag, sess.countryCode) + if len(rewritten) > 0 { + req = append(req, rewritten) + } + } + opt = rewriteTagSlice(opt, sess.countryCode) + + // Check if the query contains terms that the user is not allowed to use. + if restr, _, _ := stringSliceDelta(t.tags, + filterTags(append(types.FlattenDoubleSlice(req), opt...), globals.maskedTagNS)); len(restr) > 0 { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("attempt to search by restricted tags") + } + + // Ordinary users: find only active topics and accounts. + // Root users: find all topics and accounts, including suspended and soft-deleted. + subs, err = store.Users.FindSubs(t.tenantID, asUid, globals.aliasTagNS, req, opt, sess.authLvl != auth.LevelRoot) + if err != nil { + sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, incomingReqTs, nil)) + return err + } + } + } + } + case types.TopicCatP2P: + // TODO(gene): don't load subs from DB, use perUserData - it already contains subscriptions. + // No need to load Public for p2p topics. + if ifModified.IsZero() { + // No cache management. Skip deleted subscriptions. + subs, err = store.Topics.GetSubs(t.tenantID, t.name, msgOpts2storeOpts(req)) + } else { + // User manages cache. Include deleted subscriptions too. + subs, err = store.Topics.GetSubsAny(t.tenantID, t.name, msgOpts2storeOpts(req)) + } + case types.TopicCatGrp: + topicName := t.name + if asChan { + // In case of a channel allow fetching the subscription of the current user only. + if req == nil { + req = &MsgGetOpts{} + } + req.User = asUid.UserId() + // Channel subscribers are using chnXXX topic name rather than grpXXX. + topicName = msg.Original + } + // Include sub.Public. + if ifModified.IsZero() { + // No cache management. Skip deleted subscriptions. + subs, err = store.Topics.GetUsers(t.tenantID, topicName, msgOpts2storeOpts(req)) + } else { + // User manages cache. Include deleted subscriptions too. + subs, err = store.Topics.GetUsersAny(t.tenantID, topicName, msgOpts2storeOpts(req)) + } + // Do nothing for all other topic types, like 'sys', 'slf'. + } + + if err != nil { + sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, incomingReqTs, nil)) + return err + } + + if len(subs) == 0 { + // Inform the client that there are no subscriptions. + sess.queueOut(NoContentParamsReply(msg, now, map[string]any{"what": "sub"})) + return nil + } + + meta := &MsgServerMeta{ + Id: id, + Topic: msg.Original, + Sub: make([]MsgTopicSub, 0, len(subs)), + Timestamp: &now} + presencer := (userData.modeGiven & userData.modeWant).IsPresencer() + sharer := (userData.modeGiven & userData.modeWant).IsSharer() + + for i := range subs { + sub := &subs[i] + // Indicator if the requester has provided a cut off date for ts of pub & priv updates. + var sendPubPriv bool + var banned bool + var mts MsgTopicSub + deleted := sub.DeletedAt != nil + + if ifModified.IsZero() { + sendPubPriv = true + } else { + // Skip sending deleted subscriptions if they were deleted before the cut off date. + // If they are freshly deleted send minimum info + if deleted { + if !sub.DeletedAt.After(ifModified) { + continue + } + mts.DeletedAt = sub.DeletedAt + } + sendPubPriv = !deleted && sub.UpdatedAt.After(ifModified) + } + + uid := types.ParseUid(sub.User) + subMode := sub.ModeGiven & sub.ModeWant + isReader := subMode.IsReader() + if t.cat == types.TopicCatMe { + // Mark subscriptions that the user does not care about. + if !subMode.IsJoiner() { + banned = true + } + + // Reporting user's subscriptions to other topics. P2P topic name is the + // UID of the other user. + with := sub.GetWith() + if with != "" { + mts.Topic = with + mts.Online = t.perSubs[with].online && !deleted && presencer + } else if strings.HasPrefix(sub.Topic, "slf") { + mts.Topic = "slf" + // Not reporting Online as it makes no sense for slf. + } else { + mts.Topic = sub.Topic + mts.Online = t.perSubs[sub.Topic].online && !deleted && presencer + } + + if !deleted && !banned { + if isReader { + touchedAt := sub.GetTouchedAt() + if touchedAt.IsZero() { + mts.TouchedAt = nil + } else { + mts.TouchedAt = &touchedAt + } + mts.SeqId = sub.GetSeqId() + mts.DelId = sub.DelId + } else if !sub.UpdatedAt.IsZero() { + mts.TouchedAt = &sub.UpdatedAt + } + + lastSeen := sub.GetLastSeen() + if lastSeen != nil && !mts.Online { + mts.LastSeen = &MsgLastSeenInfo{ + When: lastSeen, + UserAgent: sub.GetUserAgent(), + } + } + + mts.SubCnt = sub.GetSubCnt() + } + } else { + // Mark subscriptions that the user does not care about. + if t.cat == types.TopicCatGrp && !subMode.IsJoiner() { + banned = true + } + + // Reporting subscribers to fnd, a group or a p2p topic + mts.User = uid.UserId() + if t.cat == types.TopicCatFnd { + mts.Topic = sub.Topic + } + + if !deleted { + if uid == asUid && isReader && !banned { + // Report deleted ID for own subscriptions only + mts.DelId = sub.DelId + } + + if t.cat == types.TopicCatGrp { + pud := t.perUser[uid] + mts.Online = pud.online > 0 && presencer + } + } + } + + if !deleted { + if !sub.UpdatedAt.IsZero() { + mts.UpdatedAt = &sub.UpdatedAt + } + + if isReader && !banned { + mts.ReadSeqId = sub.ReadSeqId + mts.RecvSeqId = sub.RecvSeqId + } + + if t.cat != types.TopicCatFnd { + // p2p and grp + if !sub.IsDummy() && (sharer || uid == asUid || subMode.IsAdmin()) { + // If user is not a sharer, the access mode of other ordinary users if not accessible. + // Own and admin permissions only are visible to non-sharers. + mts.Acs.Mode = subMode.String() + mts.Acs.Want = sub.ModeWant.String() + mts.Acs.Given = sub.ModeGiven.String() + } + } else { + // Topic 'fnd' + // sub.ModeXXX may be defined by the plugin. + if sub.ModeGiven.IsDefined() && sub.ModeWant.IsDefined() { + mts.Acs.Mode = subMode.String() + mts.Acs.Want = sub.ModeWant.String() + mts.Acs.Given = sub.ModeGiven.String() + } else if types.IsChannel(sub.Topic) { + mts.Acs.Mode = types.ModeCChnReader.String() + } else if defacs := sub.GetDefaultAccess(); defacs != nil { + switch authLevel { + case auth.LevelAnon: + mts.Acs.Mode = defacs.Anon.String() + case auth.LevelAuth, auth.LevelRoot: + mts.Acs.Mode = defacs.Auth.String() + } + } + mts.SubCnt = sub.GetSubCnt() + } + + // Returning public and private only if they have changed since ifModified + if sendPubPriv { + // 'sub' has nil 'public'/'trusted' in P2P topics which is OK. + mts.Public = sub.GetPublic() + mts.Trusted = sub.GetTrusted() + // Reporting 'private' only if it's user's own subscription. + if uid == asUid { + mts.Private = sub.Private + } + } + + // Always reporting 'private' for fnd topic. + if t.cat == types.TopicCatFnd { + mts.Private = sub.Private + } + } + + meta.Sub = append(meta.Sub, mts) + } + + sess.queueOut(&ServerComMessage{Meta: meta}) + + return nil +} + +// replySetSub is a response to new subscription request or an update to a subscription {set.sub}: +// update topic metadata cache, save/update subs, reply to the caller as {ctrl} message, +// generate a presence notification, if appropriate. +func (t *Topic) replySetSub(sess *Session, pkt *ClientComMessage, asChan bool) error { + now := types.TimeNow() + + asUid := types.ParseUserId(pkt.AsUser) + set := pkt.Set + + var target types.Uid + if target = types.ParseUserId(set.Sub.User); target.IsZero() && set.Sub.User != "" { + // Invalid user ID + sess.queueOut(ErrMalformedReply(pkt, now)) + return errors.New("invalid user id") + } + + // if set.User is not set, request is for the current user + if target.IsZero() { + target = asUid + } + + var err error + var modeChanged *MsgAccessMode + if target == asUid { + // Request new subscription or modify own subscription + modeChanged, err = t.thisUserSub(sess, pkt, asUid, asChan, set.Sub.Mode, nil) + } else { + // Request to approve/change someone's subscription + modeChanged, err = t.anotherUserSub(sess, asUid, target, asChan, pkt) + } + if err != nil { + return err + } + + var resp *ServerComMessage + if modeChanged != nil { + // Report resulting access mode. + params := map[string]any{"acs": modeChanged} + if target != asUid { + params["user"] = target.UserId() + } + resp = NoErrParamsReply(pkt, now, params) + } else { + resp = InfoNotModifiedReply(pkt, now) + } + + sess.queueOut(resp) + + return nil +} + +// replyGetData is a response to a get.data request - load a list of stored messages, send them to session as {data} +// response goes to a single session rather than all sessions in a topic +func (t *Topic) replyGetData(sess *Session, asUid types.Uid, asChan bool, req *MsgGetOpts, msg *ClientComMessage) error { + now := types.TimeNow() + toriginal := t.original(asUid) + + if req != nil && (req.IfModifiedSince != nil || req.User != "" || req.Topic != "") { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("invalid MsgGetOpts query") + } + + // Check if the user has permission to read the topic data + count := 0 + if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() { + // Read messages from DB + messages, err := store.Messages.GetAll(t.tenantID, t.name, asUid, msgOpts2storeOpts(req)) + if err != nil { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + + // Push the list of messages to the client as {data}. + if messages != nil { + count = len(messages) + if count > 0 { + outgoingMessages := make([]*ServerComMessage, count) + for i := range messages { + mm := &messages[i] + from := "" + if !asChan { + // Don't show sender for channel readers + from = types.ParseUid(mm.From).UserId() + } + outgoingMessages[i] = &ServerComMessage{ + Data: &MsgServerData{ + Topic: toriginal, + Head: mm.Head, + SeqId: mm.SeqId, + From: from, + Timestamp: mm.CreatedAt, + Content: mm.Content, + }, + } + } + sess.queueOutBatch(outgoingMessages) + } + } + } else { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("attempt to get messages by non-reader") + } + + // Inform the requester that all the data has been served. + if count == 0 { + sess.queueOut(NoContentParamsReply(msg, now, map[string]any{"what": "data"})) + } else { + sess.queueOut(NoErrDeliveredParams(msg.Id, msg.Original, now, + map[string]any{"what": "data", "count": count})) + } + + return nil +} + +// replyGetTags returns topics' tags - tokens used for discovery. +func (t *Topic) replyGetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + now := types.TimeNow() + + if t.cat == types.TopicCatFnd { + // Fnd: checking for alias availability. + + // Checking public (session) data only. + if tag := t.fndGetPublic(sess); tag != "" { + var found string + tag, subs, err := pluginFind(t.tenantID, asUid, tag) + if err == nil { + if subs == nil { + if prefix, _ := validateTag(tag); prefix != "" { + // Check only if a fully-qualified tag was sent. Otherwise ignore the request. + found, err = store.Users.FindOne(t.tenantID, tag) + } + } else { + // The plugin returned a list of topics. Send the first one. + found = subs[0].Topic + } + } + + if err != nil { + sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Id, msg.Original, now, msg.Timestamp, nil)) + return err + } + + if found != "" { + sess.queueOut(&ServerComMessage{ + Meta: &MsgServerMeta{ + Id: msg.Id, + Topic: msg.Original, + Timestamp: &now, + Tags: []string{found}, + }, + }) + return nil + } + } + + // Inform the requester that there are no tags. + sess.queueOut(NoContentParamsReply(msg, now, map[string]string{"what": "tags"})) + return nil + } + + if t.cat != types.TopicCatMe && t.cat != types.TopicCatGrp { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("invalid topic category for getting tags") + } + if t.cat == types.TopicCatGrp && t.owner != asUid { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("request for tags from non-owner") + } + + if len(t.tags) > 0 { + sess.queueOut(&ServerComMessage{ + Meta: &MsgServerMeta{ + Id: msg.Id, + Topic: t.original(asUid), + Timestamp: &now, + Tags: t.tags, + }, + }) + return nil + } + + // Inform the requester that there are no tags. + sess.queueOut(NoContentParamsReply(msg, now, map[string]string{"what": "tags"})) + + return nil +} + +// replySetTags updates topic's tags - tokens used for discovery. +func (t *Topic) replySetTags(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + now := types.TimeNow() + + if t.cat != types.TopicCatMe && t.cat != types.TopicCatGrp { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("invalid topic category to assign tags") + } + + if t.cat == types.TopicCatGrp && t.owner != asUid { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("tags update by non-owner") + } + + tags := normalizeTags(msg.Set.Tags, globals.maxTagCount) + if len(tags) == 0 { + sess.queueOut(InfoNotModifiedReply(msg, now)) + return nil + } + + if !restrictedTagsEqual(t.tags, tags, globals.immutableTagNS) { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("attempt to mutate restricted tags") + } + + if hasDuplicateNamespaceTags(tags, globals.aliasTagNS) { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("duplicate unique tags") + } + + added, removed, _ := stringSliceDelta(t.tags, tags) + + if t.cat == types.TopicCatMe && len(added) > 0 { + // User tags must all be prefixed. Users are not rearchable by generic tags. + var prefixed []string + for _, tag := range added { + if prefix, _ := validateTag(tag); prefix != "" { + prefixed = append(prefixed, prefix) + } + } + added = prefixed + } + + if len(added) == 0 && len(removed) == 0 { + sess.queueOut(InfoNotModifiedReply(msg, now)) + return nil + } + + // Remove unprefixed tags + if unique := filterTags(added, map[string]bool{globals.aliasTagNS: true}); len(unique) > 0 { + // Check for global uniqueness. + // It's not inside a transaction, so a race may happen. + for _, tag := range unique { + result, err := store.Users.FindOne(t.tenantID, tag) + + if err != nil { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + + if result != "" { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("globally duplicate unique tags") + } + } + } + + update := map[string]any{"Tags": tags, "UpdatedAt": now} + var err error + switch t.cat { + case types.TopicCatMe: + err = store.Users.Update(t.tenantID, asUid, update) + case types.TopicCatGrp: + err = store.Topics.Update(t.tenantID, t.name, update) + } + + if err != nil { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + + t.tags = tags + t.presSubsOnline("tags", "", nilPresParams, &presFilters{singleUser: asUid.UserId()}, sess.sid) + + params := make(map[string]any) + if len(added) > 0 { + params["added"] = len(added) + } + if len(removed) > 0 { + params["removed"] = len(removed) + } + + sess.queueOut(NoErrParamsReply(msg, now, params)) + return nil +} + +// replyGetCreds returns user's credentials such as email and phone numbers. +func (t *Topic) replyGetCreds(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + now := types.TimeNow() + id := msg.Id + + if t.cat != types.TopicCatMe { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("invalid topic category for getting credentials") + } + + screds, err := store.Users.GetAllCreds(sess.tenantID, asUid, "", false) + if err != nil { + sess.queueOut(decodeStoreErrorExplicitTs(err, id, msg.Original, now, msg.Timestamp, nil)) + return err + } + + if len(screds) > 0 { + creds := make([]*MsgCredServer, len(screds)) + for i, sc := range screds { + creds[i] = &MsgCredServer{Method: sc.Method, Value: sc.Value, Done: sc.Done} + } + sess.queueOut(&ServerComMessage{ + Meta: &MsgServerMeta{ + Id: id, + Topic: t.original(asUid), + Timestamp: &now, + Cred: creds, + }, + }) + return nil + } + + // Inform the requester that there are no credentials. + sess.queueOut(NoContentParamsReply(msg, now, map[string]string{"what": "creds"})) + + return nil +} + +// replySetCred adds or validates user credentials such as email and phone numbers. +func (t *Topic) replySetCred(sess *Session, asUid types.Uid, authLevel auth.Level, msg *ClientComMessage) error { + now := types.TimeNow() + set := msg.Set + + if t.cat != types.TopicCatMe { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("invalid topic category for updating credentials") + } + + var err error + var tags []string + creds := []MsgCredClient{*set.Cred} + if set.Cred.Response != "" { + // Credential is being validated. Return an arror if response is invalid. + _, tags, err = validatedCreds(sess.tenantID, asUid, authLevel, creds, true) + } else { + // Credential is being added or updated. + tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{ + TenantID: sess.tenantID, + Uid: asUid, + AuthLevel: auth.LevelNone, + Lifetime: auth.Duration(time.Hour * 24), + Features: auth.FeatureNoLogin, + }) + _, tags, err = addCreds(sess.tenantID, asUid, creds, nil, sess.lang, tmpToken) + } + + if tags != nil { + t.tags = tags + t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") + } + + sess.queueOut(decodeStoreErrorExplicitTs(err, set.Id, t.original(asUid), now, msg.Timestamp, nil)) + + return err +} + +// replyGetAux returns topic's auxiliary set of key-value pairs. +func (t *Topic) replyGetAux(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + now := types.TimeNow() + + if t.cat != types.TopicCatP2P && t.cat != types.TopicCatGrp && t.cat != types.TopicCatSlf { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("invalid topic category to query aux") + } + + if len(t.aux) > 0 { + sess.queueOut(&ServerComMessage{ + Meta: &MsgServerMeta{ + Id: msg.Id, + Topic: t.original(asUid), + Timestamp: &now, + Aux: t.aux, + }, + }) + return nil + } + + // Inform the requester that there are no tags. + sess.queueOut(NoContentParamsReply(msg, now, map[string]string{"what": "aux"})) + + return nil +} + +// replyGetAux returns topic's auxiliary set of key-value pairs. +func (t *Topic) replySetAux(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + now := types.TimeNow() + + if t.cat != types.TopicCatP2P && t.cat != types.TopicCatGrp && t.cat != types.TopicCatSlf { + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("invalid topic category to assign aux") + } + + if userData := t.perUser[asUid]; !(userData.modeGiven & userData.modeWant).IsAdmin() { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("aux update by non-admin") + } + + if aux, changed := mergeMaps(copyMap(t.aux), msg.Set.Aux); changed { + err := store.Topics.Update(t.tenantID, t.name, map[string]any{"Aux": aux, "UpdatedAt": now}) + if err == nil { + t.aux = aux + t.presSubsOnline("aux", "", nilPresParams, nilPresFilters, sess.sid) + } + sess.queueOut(decodeStoreErrorExplicitTs(err, msg.Set.Id, t.original(asUid), now, msg.Timestamp, nil)) + return err + } + + sess.queueOut(InfoNotModifiedReply(msg, now)) + return nil +} + +// replyGetDel is a response to a get[what=del] request: load a list of deleted message ids, send them to +// a session as {meta} +// response goes to a single session rather than all sessions in a topic +func (t *Topic) replyGetDel(sess *Session, asUid types.Uid, req *MsgGetOpts, msg *ClientComMessage) error { + now := types.TimeNow() + toriginal := t.original(asUid) + + id := msg.Id + incomingReqTs := msg.Timestamp + + if req != nil && (req.IfModifiedSince != nil || req.User != "" || req.Topic != "") { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("invalid MsgGetOpts query") + } + + // Check if the user has permission to read the topic data and the request is valid. + if userData := t.perUser[asUid]; (userData.modeGiven & userData.modeWant).IsReader() { + ranges, delID, err := store.Messages.GetDeleted(t.tenantID, t.name, asUid, msgOpts2storeOpts(req)) + if err != nil { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + + if len(ranges) > 0 { + sess.queueOut(&ServerComMessage{ + Meta: &MsgServerMeta{ + Id: id, + Topic: toriginal, + Del: &MsgDelValues{ + DelId: delID, + DelSeq: rangeDeserialize(ranges), + }, + Timestamp: &now, + }, + }) + return nil + } + } + + sess.queueOut(NoContentParams(id, toriginal, now, incomingReqTs, map[string]string{"what": "del"})) + + return nil +} + +// replyDelMsg deletes (soft or hard) messages in response to del.msg packet. +func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *ClientComMessage) error { + now := types.TimeNow() + + if asChan { + // Do not allow channel readers delete messages. + sess.queueOut(ErrOperationNotAllowedReply(msg, now)) + return errors.New("channel readers cannot delete messages") + } + + del := msg.Del + + pud := t.perUser[asUid] + if !(pud.modeGiven & pud.modeWant).IsDeleter() { + // User must have an R permission: if the user cannot read messages, he has + // no business of deleting them. + if !(pud.modeGiven & pud.modeWant).IsReader() { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("del.msg: permission denied") + } + + // User has just the R permission, cannot hard-delete messages, silently + // switching to soft-deleting + del.Hard = false + } + + var err error + var ranges []types.Range + if len(del.DelSeq) == 0 { + err = errors.New("del.msg: no IDs to delete") + } else { + count := 0 + for _, dq := range del.DelSeq { + if dq.LowId > t.lastID || dq.LowId < 0 || dq.HiId < 0 || + (dq.HiId > 0 && dq.LowId > dq.HiId) || + (dq.LowId == 0 && dq.HiId == 0) { + err = errors.New("del.msg: invalid entry in list") + break + } + + if dq.HiId > t.lastID { + // Range is inclusive - exclusive [low, hi), + // to delete all messages hi must be lastId + 1 + dq.HiId = t.lastID + 1 + } else if dq.LowId == dq.HiId || dq.LowId+1 == dq.HiId { + dq.HiId = 0 + } + + if dq.HiId == 0 { + count++ + } else { + count += dq.HiId - dq.LowId + } + + ranges = append(ranges, types.Range{Low: dq.LowId, Hi: dq.HiId}) + } + + if err == nil { + // Sort by Low ascending then by Hi descending. + sort.Sort(types.RangeSorter(ranges)) + // Collapse overlapping ranges + ranges = types.RangeSorter(ranges).Normalize() + } + + if count > defaultMaxDeleteCount && len(ranges) > 1 { + err = errors.New("del.msg: too many messages to delete") + } + } + + if err != nil { + sess.queueOut(ErrMalformedReply(msg, now)) + return err + } + + forUser := asUid + var age time.Duration + if del.Hard { + forUser = types.ZeroUid + age = globals.msgDeleteAge + } + if err = store.Messages.DeleteList(t.tenantID, t.name, t.delID+1, forUser, age, ranges); err != nil { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + + // Increment Delete transaction ID + t.delID++ + dr := rangeDeserialize(ranges) + if del.Hard { + for uid, pud := range t.perUser { + pud.delID = t.delID + t.perUser[uid] = pud + + // Update unread counters for all users who may have had these messages as unread + if (pud.modeGiven & pud.modeWant).IsReader() { + // Calculate how many unread messages were deleted for this user + unreadDeleted := calculateUnreadInRanges(pud.readID, t.lastID, ranges) + if unreadDeleted > 0 { + // Decrease unread count (negative value) + usersUpdateUnread(t.tenantID, uid, -unreadDeleted, true) + } + } + } + + // Broadcast the change to all, online and offline, exclude the session making the change. + params := &presParams{delID: t.delID, delSeq: dr, actor: asUid.UserId()} + filters := &presFilters{filterIn: types.ModeRead} + t.presSubsOnline("del", params.actor, params, filters, sess.sid) + t.presSubsOffline("del", params, filters, nilPresFilters, sess.sid, true) + } else { + pud := t.perUser[asUid] + pud.delID = t.delID + t.perUser[asUid] = pud + + // Notify user's other sessions + t.presPubMessageDelete(asUid, pud.modeGiven&pud.modeWant, t.delID, dr, sess.sid) + } + + sess.queueOut(NoErrParamsReply(msg, now, map[string]int{"del": t.delID})) + + return nil +} + +// Handle request to delete the topic {del what="topic"}. +// 1. If requester is the owner then it should have been handled at the hub, log an error. +// 2. If requester is not the owner, treat it like {leave unsub=true}. +func (t *Topic) replyDelTopic(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + if t.owner != asUid { + return t.replyLeaveUnsub(sess, msg, asUid) + } + + // This is an indication of a bug. + logs.Err.Println("replyDelTopic called by owner (SHOULD NOT HAPPEN!)") + return nil +} + +// Delete credential +func (t *Topic) replyDelCred(sess *Session, asUid types.Uid, authLvl auth.Level, msg *ClientComMessage) error { + now := types.TimeNow() + incomingReqTs := msg.Timestamp + del := msg.Del + + if t.cat != types.TopicCatMe { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("del.cred: invalid topic category") + } + if del.Cred == nil || del.Cred.Method == "" { + sess.queueOut(ErrMalformedReply(msg, now)) + return errors.New("del.cred: missing method") + } + + tags, err := deleteCred(sess.tenantID, asUid, authLvl, del.Cred) + if tags != nil { + // Check if anything has been actually removed. + _, removed, _ := stringSliceDelta(t.tags, tags) + if len(removed) > 0 { + t.tags = tags + t.presSubsOnline("tags", "", nilPresParams, nilPresFilters, "") + } + } else if err == nil { + sess.queueOut(InfoNoActionReply(msg, now)) + return nil + } + sess.queueOut(decodeStoreErrorExplicitTs(err, del.Id, del.Topic, now, incomingReqTs, nil)) + return err +} + +// Delete subscription. +func (t *Topic) replyDelSub(sess *Session, asUid types.Uid, msg *ClientComMessage) error { + now := types.TimeNow() + del := msg.Del + + asChan, err := t.verifyChannelAccess(msg.Original) + if err != nil { + // User should not be able to address non-channel topic as channel. + sess.queueOut(ErrNotFoundReply(msg, now)) + return types.ErrNotFound + } + if asChan { + // Don't allow channel readers to delete self-subscription. Use leave-unsub or del-topic. + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return errors.New("channel access denied: cannot delete subscription") + } + + // Get ID of the affected user + uid := types.ParseUserId(del.User) + + pud := t.perUser[asUid] + if !(pud.modeGiven & pud.modeWant).IsAdmin() { + err = errors.New("del.sub: permission denied") + } else if uid.IsZero() || uid == asUid { + // Cannot delete self-subscription. User [leave unsub] or [delete topic] + err = errors.New("del.sub: cannot delete self-subscription") + } else if t.cat == types.TopicCatP2P { + // Don't try to delete the other P2P user + err = errors.New("del.sub: cannot apply to a P2P topic") + } + + if err != nil { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return err + } + + pud, ok := t.perUser[uid] + if !ok { + sess.queueOut(InfoNoActionReply(msg, now)) + return errors.New("del.sub: user not found") + } + + // Check if the user being ejected is the owner. + if (pud.modeGiven & pud.modeWant).IsOwner() { + err = errors.New("del.sub: cannot evict topic owner") + } else if !pud.modeWant.IsJoiner() { + // If the user has banned the topic, subscription should not be deleted. Otherwise user may be re-invited + // which defeats the purpose of banning. + err = errors.New("del.sub: cannot delete banned subscription") + } + + if err != nil { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + return err + } + + // Delete user's subscription from the database + if err := store.Subs.Delete(t.tenantID, t.name, uid); err != nil { + if err == types.ErrNotFound { + sess.queueOut(InfoNoActionReply(msg, now)) + } else { + sess.queueOut(ErrUnknownReply(msg, now)) + return err + } + } else { + sess.queueOut(NoErrReply(msg, now)) + } + + // Update cached unread count: negative value + if (pud.modeWant & pud.modeGiven).IsReader() { + usersUpdateUnread(t.tenantID, uid, pud.readID-t.lastID, true) + } + + // ModeUnset signifies deleted subscription as opposite to ModeNone - no access. + t.notifySubChange(uid, asUid, false, + pud.modeWant, pud.modeGiven, types.ModeUnset, types.ModeUnset, sess.sid) + + t.evictUser(uid, true, "") + + // Notify plugins. + pluginSubscription(&types.Subscription{TenantID: t.tenantID, Topic: t.name, User: uid.String()}, plgActDel) + + // If all P2P users were deleted, suspend the topic to let it shut down. + if t.cat == types.TopicCatP2P && t.subsCount() == 0 { + t.markPaused(true) + globals.hub.unreg <- &topicUnreg{tenantID: t.tenantID, del: true, sess: nil, rcptTo: t.name, pkt: nil} + } + + return nil +} + +// replyLeaveUnsub is a request to unsubscribe user and detach all user's sessions from topic. +func (t *Topic) replyLeaveUnsub(sess *Session, msg *ClientComMessage, asUid types.Uid) error { + now := types.TimeNow() + + if asUid.IsZero() { + panic("replyLeaveUnsub: zero asUid") + } + + if t.owner == asUid { + if msg.init { + sess.queueOut(ErrPermissionDeniedReply(msg, now)) + } + return errors.New("replyLeaveUnsub: owner cannot unsubscribe") + } + + var err error + var asChan bool + if msg.init { + asChan, err = t.verifyChannelAccess(msg.Original) + if err != nil { + sess.queueOut(ErrNotFoundReply(msg, now)) + return errors.New("replyLeaveUnsub: incorrect addressing of channel") + } + } + + pud := t.perUser[asUid] + // Delete user's subscription from the database; msg could be nil, so cannot use msg.Original. + if pud.isChan { + // Handle channel reader. + err = store.Subs.Delete(t.tenantID, types.GrpToChn(t.name), asUid) + } else { + // Handle subscriber. + err = store.Subs.Delete(t.tenantID, t.name, asUid) + } + + if err != nil { + if msg.init { + if err == types.ErrNotFound { + sess.queueOut(InfoNoActionReply(msg, now)) + err = nil + } else { + sess.queueOut(ErrUnknownReply(msg, now)) + } + } + return err + } + + if msg.init { + sess.queueOut(NoErrReply(msg, now)) + } + + var oldWant types.AccessMode + var oldGiven types.AccessMode + if !asChan { + // Update cached unread count: negative value + if (pud.modeWant & pud.modeGiven).IsReader() { + usersUpdateUnread(t.tenantID, asUid, pud.readID-t.lastID, true) + } + oldWant, oldGiven = pud.modeWant, pud.modeGiven + } else { + oldWant, oldGiven = types.ModeCChnReader, types.ModeCChnReader + // Unsubscribe user's devices from the channel (FCM topic). + t.channelSubUnsub(asUid, false) + } + + // Send prsence notifictions to admins, other users, and user's other sessions. + t.notifySubChange(asUid, asUid, asChan, oldWant, oldGiven, types.ModeUnset, types.ModeUnset, sess.sid) + + // Evict all user's sessions, clear cached data, send notifications. + t.evictUser(asUid, true, sess.sid) + + // Notify plugins. + pluginSubscription(&types.Subscription{TenantID: t.tenantID, Topic: t.name, User: asUid.String()}, plgActDel) + + if t.cat == types.TopicCatGrp { + // Decrement group's cached member count. + t.subCnt-- + } + + // If all P2P users were deleted, suspend the topic to let it shut down. + if t.cat == types.TopicCatP2P && t.subsCount() == 0 { + t.markPaused(true) + globals.hub.unreg <- &topicUnreg{tenantID: t.tenantID, del: true, sess: nil, rcptTo: t.name, pkt: nil} + } + + return nil +} + +// evictUser evicts all given user's sessions from the topic and clears user's cached data, if appropriate. +func (t *Topic) evictUser(uid types.Uid, unsub bool, skip string) { + now := types.TimeNow() + pud, ok := t.perUser[uid] + + // Detach user from topic + if unsub { + if t.cat == types.TopicCatP2P { + // P2P: mark user as deleted + pud.online = 0 + pud.deleted = true + t.perUser[uid] = pud + } else if ok { + // Grp: delete per-user data + delete(t.perUser, uid) + t.computePerUserAcsUnion() + + if !pud.isChan { + usersRegisterUser(t.tenantID, uid, false) + } + } + } else if ok { + if pud.isChan { + delete(t.perUser, uid) + // No need to call computePerUserAcsUnion because removal of a channel reader does not change union permissions. + // No need to unregister user as we ignore unread channel messages. + } else { + // Clear online status + pud.online = 0 + t.perUser[uid] = pud + } + } + + // Detach all user's sessions + msg := NoErrEvicted("", t.original(uid), now) + msg.Ctrl.Params = map[string]any{"unsub": unsub} + msg.SkipSid = skip + msg.uid = uid + msg.AsUser = uid.UserId() + for s := range t.sessions { + if pssd, removed := t.remSession(s, uid); pssd != nil { + if removed { + s.detachSession(t.name) + } + if s.sid != skip { + s.queueOut(msg) + } + } + } +} + +// User's subscription to a topic has changed, send presence notifications. +// 1. New subscription +// 2. Deleted subscription +// 3. Permissions changed +// Sending to +// (a) Topic admins online on topic itself. +// (b) Topic admins offline on 'me' if approval is needed. +// (c) If subscription is deleted, 'gone' to target. +// (d) 'off' to topic members online if deleted or muted. +// (e) To target user. +func (t *Topic) notifySubChange(uid, actor types.Uid, isChan bool, + oldWant, oldGiven, newWant, newGiven types.AccessMode, skip string) { + + unsub := newWant == types.ModeUnset || newGiven == types.ModeUnset + + target := uid.UserId() + + dWant := types.ModeNone.String() + if newWant.IsDefined() { + if oldWant.IsDefined() && !oldWant.IsZero() { + dWant = oldWant.Delta(newWant) + } else { + dWant = newWant.String() + } + } + + dGiven := types.ModeNone.String() + if newGiven.IsDefined() { + if oldGiven.IsDefined() && !oldGiven.IsZero() { + dGiven = oldGiven.Delta(newGiven) + } else { + dGiven = newGiven.String() + } + } + params := &presParams{ + target: target, + actor: actor.UserId(), + dWant: dWant, + dGiven: dGiven, + } + + filterSharers := &presFilters{ + filterIn: types.ModeCSharer, + excludeUser: target, + } + + // Announce the change in permissions to the admins who are online in the topic, exclude the target + // and exclude the actor's session. + t.presSubsOnline("acs", target, params, filterSharers, skip) + + // If it's a new subscription or if the user asked for permissions in excess of what was granted, + // announce the request to topic admins on 'me' so they can approve the request. The notification + // is not sent to the target user or the actor's session. + if newWant.BetterThan(newGiven) || oldWant == types.ModeNone { + t.presSubsOffline("acs", params, filterSharers, filterSharers, skip, true) + } + + // Handling of muting/unmuting. + // Case A: subscription deleted. + // Case B: subscription muted only. + if unsub { + // Subscription deleted. + + // In case of a P2P topic subscribe/unsubscribe users from each other's notifications. + if t.cat == types.TopicCatP2P { + uid2 := t.p2pOtherUser(uid) + // Remove user1's subscription to user2 and notify user1's other sessions that he is gone. + t.presSingleUserOffline(uid, newWant&newGiven, "gone", nilPresParams, skip, false) + // Tell user2 that user1 is offline but let him keep sending updates in case user1 resubscribes. + presSingleUserOfflineOffline(t.tenantID, uid2, target, "off", nilPresParams, "") + } else if t.cat == types.TopicCatGrp && !isChan { + // Notify all sharers that the user is offline now. + t.presSubsOnline("off", uid.UserId(), nilPresParams, filterSharers, skip) + // Notify target that the subscription is gone. + presSingleUserOfflineOffline(t.tenantID, uid, t.name, "gone", nilPresParams, skip) + } + } else { + // Subscription altered. + + if !(newWant & newGiven).IsPresencer() && (oldWant & oldGiven).IsPresencer() { + // Subscription just muted. + + var source string + if t.cat == types.TopicCatP2P { + source = t.p2pOtherUser(uid).UserId() + } else if t.cat == types.TopicCatGrp && !isChan { + source = t.name + } + if source != "" { + // Tell user1 to start discarding updates from muted topic/user. + presSingleUserOfflineOffline(t.tenantID, uid, source, "off+dis", nilPresParams, "") + } + + } else if (newWant & newGiven).IsPresencer() && !(oldWant & oldGiven).IsPresencer() { + // Subscription un-muted. + + // Notify subscriber of topic's online status. + if t.cat == types.TopicCatGrp && !isChan { + t.presSingleUserOffline(uid, newWant&newGiven, "?unkn+en", nilPresParams, "", false) + } else if t.cat == types.TopicCatMe { + // User is visible online now, notify subscribers. + t.presUsersOfInterest("on+en", t.userAgent) + } + } + + // Notify target that permissions have changed. + + // Notify sessions online in the topic. + t.presSubsOnlineDirect("acs", params, &presFilters{singleUser: target}, skip) + // Notify target's other sessions on 'me'. + t.presSingleUserOffline(uid, newWant&newGiven, "acs", params, skip, true) + } +} + +// FIXME: this won't work correctly with multiplexing sessions. +func (t *Topic) mostRecentSession() *Session { + var sess *Session + var latest int64 + for s := range t.sessions { + sessionLastAction := atomic.LoadInt64(&s.lastAction) + if sessionLastAction > latest { + sess = s + latest = sessionLastAction + } + } + return sess +} + +const ( + // Topic is fully initialized. + topicStatusLoaded = 0x1 + // Topic is paused: all packets are rejected. + topicStatusPaused = 0x2 + + // Topic is in the process of being deleted. This is irrecoverable. + topicStatusMarkedDeleted = 0x10 + // Topic is suspended: read-only mode. + topicStatusReadOnly = 0x20 +) + +// statusChangeBits sets or removes given bits from t.status +func (t *Topic) statusChangeBits(bits int32, set bool) { + for { + oldStatus := atomic.LoadInt32(&t.status) + newStatus := oldStatus + if set { + newStatus |= bits + } else { + newStatus &= ^bits + } + if newStatus == oldStatus { + break + } + if atomic.CompareAndSwapInt32(&t.status, oldStatus, newStatus) { + break + } + } +} + +// markLoaded indicates that topic subscribers have been loaded into memory. +func (t *Topic) markLoaded() { + t.statusChangeBits(topicStatusLoaded, true) +} + +// markPaused pauses or unpauses the topic. When the topic is paused all +// messages are rejected. +func (t *Topic) markPaused(pause bool) { + t.statusChangeBits(topicStatusPaused, pause) +} + +// markDeleted marks topic as being deleted. +func (t *Topic) markDeleted() { + t.statusChangeBits(topicStatusMarkedDeleted, true) +} + +// markReadOnly suspends/un-suspends the topic: adds or removes the 'read-only' flag. +func (t *Topic) markReadOnly(readOnly bool) { + t.statusChangeBits(topicStatusReadOnly, readOnly) +} + +// isInactive checks if topic is paused or being deleted. +func (t *Topic) isInactive() bool { + return (atomic.LoadInt32(&t.status) & (topicStatusPaused | topicStatusMarkedDeleted)) != 0 +} + +func (t *Topic) isReadOnly() bool { + return (atomic.LoadInt32(&t.status) & topicStatusReadOnly) != 0 +} + +func (t *Topic) isLoaded() bool { + return (atomic.LoadInt32(&t.status) & topicStatusLoaded) != 0 +} + +func (t *Topic) isDeleted() bool { + return (atomic.LoadInt32(&t.status) & topicStatusMarkedDeleted) != 0 +} + +// Get topic name suitable for the given client +func (t *Topic) original(uid types.Uid) string { + if t.cat == types.TopicCatP2P { + if pud, ok := t.perUser[uid]; ok { + return pud.topicName + } + panic("Invalid P2P topic") + } + + if t.cat == types.TopicCatGrp && t.isChan { + if t.perUser[uid].isChan { + // This is a channel reader. + return types.GrpToChn(t.xoriginal) + } + } + return t.xoriginal +} + +// Get ID of the other user in a P2P topic +func (t *Topic) p2pOtherUser(uid types.Uid) types.Uid { + if t.cat == types.TopicCatP2P { + // Try to find user in subscribers. + for u2 := range t.perUser { + if u2.Compare(uid) != 0 { + return u2 + } + } + } + + // Even when one user is deleted, the subscription must be restored + // before p2pOtherUser is called. + panic("Not a valid P2P topic") +} + +// Get per-session value of fnd.Public +func (t *Topic) fndGetPublic(sess *Session) string { + if t.cat == types.TopicCatFnd { + if t.public == nil { + return "" + } + if pubmap, ok := t.public.(map[string]any); ok { + if public, ok := pubmap[sess.sid].(string); ok { + return public + } + return "" + } + panic("Invalid Fnd.Public type") + } + panic("Not Fnd topic") +} + +// Assign per-session fnd.Public. Returns true if value has been changed. +func (t *Topic) fndSetPublic(sess *Session, public any) bool { + if t.cat != types.TopicCatFnd { + panic("Not Fnd topic") + } + + var pubmap map[string]any + var ok bool + if t.public != nil { + if pubmap, ok = t.public.(map[string]any); !ok { + // This could only happen if fnd.public is assigned outside of this function. + panic("Invalid Fnd.Public type") + } + } + if pubmap == nil { + pubmap = make(map[string]any) + } + + if public != nil { + pubmap[sess.sid] = public + } else { + ok = (pubmap[sess.sid] != nil) + delete(pubmap, sess.sid) + if len(pubmap) == 0 { + pubmap = nil + } + } + t.public = pubmap + return ok +} + +// Remove per-session value of fnd.Public. +func (t *Topic) fndRemovePublic(sess *Session) { + if t.public == nil { + return + } + // FIXME: case of a multiplexing session won't work correctly. + // Maybe handle it at the proxy topic. + if pubmap, ok := t.public.(map[string]any); ok { + delete(pubmap, sess.sid) + return + } + panic("Invalid Fnd.Public type") +} + +func (t *Topic) accessFor(authLvl auth.Level) types.AccessMode { + return selectAccessMode(authLvl, t.accessAnon, t.accessAuth, getDefaultAccess(t.cat, true, false)) +} + +// subsCount returns the number of topic subscribers. This method is different from subCnt with respect to channels: +// * subsCount counts subscribers + attached channel users. +// * subCnt counts all subscribers (including all channel users). +func (t *Topic) subsCount() int { + if t.cat == types.TopicCatP2P { + count := 0 + for uid := range t.perUser { + if !t.perUser[uid].deleted { + count++ + } + } + return count + } + return len(t.perUser) +} + +// Add session record. 'user' may be different from sess.uid. +func (t *Topic) addSession(sess *Session, asUid types.Uid, isChanSub bool) { + s := sess + if sess.multi != nil { + s = s.multi + } + + if pssd, ok := t.sessions[s]; ok { + // Subscription already exists. + if s.isMultiplex() && !sess.background { + // This slice is expected to be relatively short. + // Not doing anything fancy here like maps or sorting. + pssd.muids = append(pssd.muids, asUid) + t.sessions[s] = pssd + } + // Maybe panic here. + return + } + + if s.isMultiplex() { + if sess.background { + t.sessions[s] = perSessionData{} + } else { + t.sessions[s] = perSessionData{muids: []types.Uid{asUid}, isChanSub: isChanSub} + } + } else { + t.sessions[s] = perSessionData{uid: asUid, isChanSub: isChanSub} + } +} + +// Disconnects session from topic if either one of the following is true: +// * 's' is an ordinary session AND ('asUid' is zero OR 'asUid' matches subscribed user). +// * 's' is a multiplexing session and it's being dropped all together ('asUid' is zero ). +// If 's' is a multiplexing session and asUid is not zero, it's removed from the list of session +// users 'muids'. +// Returns perSessionData if it was found and true if session was actually detached from topic. +func (t *Topic) remSession(sess *Session, asUid types.Uid) (*perSessionData, bool) { + s := sess + if sess.multi != nil { + s = s.multi + } + pssd, ok := t.sessions[s] + if !ok { + // Session not found at all. + return nil, false + } + + if pssd.uid == asUid || asUid.IsZero() { + delete(t.sessions, s) + return &pssd, true + } + + for i := range pssd.muids { + if pssd.muids[i] == asUid { + pssd.muids[i] = pssd.muids[len(pssd.muids)-1] + pssd.muids = pssd.muids[:len(pssd.muids)-1] + t.sessions[s] = pssd + if len(pssd.muids) == 0 { + delete(t.sessions, s) + return &pssd, true + } + + return &pssd, false + } + } + + return nil, false +} + +// Check if topic has any online (non-background) users. +func (t *Topic) isOnline() bool { + // Find at least one non-background session. + for s, pssd := range t.sessions { + if s.isMultiplex() && len(pssd.muids) > 0 { + return true + } + if !s.background { + return true + } + } + return false +} + +// Verifies if topic can be access by the provided name: access any topic as non-channel, access channel as channel. +// Returns true if access is for channel, false if not and error if access is invalid. +func (t *Topic) verifyChannelAccess(asTopic string) (bool, error) { + if !types.IsChannel(asTopic) { + return false, nil + } + if t.isChan { + return true, nil + } + return false, types.ErrNotFound +} + +// Infer topic category from name. +func topicCat(name string) types.TopicCat { + return types.GetTopicCat(name) +} + +// Generate the name of the group topic as a "grp" followed by random-looking +// unique string. +func genTopicName() string { + return "grp" + store.Store.GetUidString() +} + +// Convert expanded (routable) topic name into name suitable for sending to the user. +// For example p2pAbCDef123 -> usrAbCDef +func topicNameForUser(name string, uid types.Uid, isChan bool) string { + switch topicCat(name) { + case types.TopicCatMe: + return "me" + case types.TopicCatFnd: + return "fnd" + case types.TopicCatP2P: + topic, _ := types.P2PNameForUser(uid, name) + return topic + case types.TopicCatGrp: + if isChan { + return types.GrpToChn(name) + } + } + return name +} + +// calculateUnreadInRanges calculates how many unread messages are within the given ranges. +// unreadStart is the first unread message SeqId (readID + 1), unreadEnd is the last possible message SeqId. +// Assumes ranges are sorted by Low ascending. +func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int { + if readID >= lastID { + // No unread messages + return 0 + } + + unreadStart := readID + 1 + unreadEnd := lastID + + // Sum up unread messages. + count := 0 + + for i := 0; i < len(ranges); i++ { + rangeStart := ranges[i].Low + rangeEnd := ranges[i].Hi + if rangeEnd == 0 { + rangeEnd = rangeStart + 1 + } + // Find the first range where rangeEnd > readID + if rangeEnd <= readID { + continue + } + + // Find intersection of [unreadStart, unreadEnd] and [rangeStart, rangeEnd) + intersectionStart := max(unreadStart, rangeStart) + intersectionEnd := min(unreadEnd+1, rangeEnd) // +1 because unreadEnd is inclusive + + if intersectionStart < intersectionEnd { + count += intersectionEnd - intersectionStart + } + } + + return count +} diff --git a/server/topic_proxy.go b/server/topic_proxy.go index e69de29..8c6ff57 100644 --- a/server/topic_proxy.go +++ b/server/topic_proxy.go @@ -0,0 +1,310 @@ +/****************************************************************************** + * Description : + * Topic in a cluster which serves as a local representation of the master + * topic hosted at another node. + *****************************************************************************/ + +package main + +import ( + "net/http" + "time" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store/types" +) + +func (t *Topic) runProxy(hub *Hub) { + killTimer := time.NewTimer(time.Hour) + killTimer.Stop() + + for { + select { + case msg := <-t.reg: + // Request to add a connection to this topic + if t.isInactive() { + msg.sess.queueOut(ErrLockedReply(msg, types.TimeNow())) + } else if err := globals.cluster.routeToTopicMaster(ProxyReqJoin, msg, t.name, msg.sess); err != nil { + // Response (ctrl message) will be handled when it's received via the proxy channel. + logs.Warn.Printf("proxy topic[%s]: route join request from proxy to master failed - %s", t.name, err) + msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow())) + } + if msg.sess.inflightReqs != nil { + msg.sess.inflightReqs.Done() + } + + case msg := <-t.unreg: + if !t.handleProxyLeaveRequest(msg, killTimer) { + sid := "nil" + if msg.sess != nil { + sid = msg.sess.sid + } + logs.Warn.Printf("proxy topic[%s]: failed to update proxy topic state for leave request - sid %s", t.name, sid) + msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow())) + } + if msg.init && msg.sess.inflightReqs != nil { + // If it's a client initiated request. + msg.sess.inflightReqs.Done() + } + + case msg := <-t.clientMsg: + // Content message intended for broadcasting to recipients + if err := globals.cluster.routeToTopicMaster(ProxyReqBroadcast, msg, t.name, msg.sess); err != nil { + logs.Warn.Printf("topic proxy[%s]: route broadcast request from proxy to master failed - %s", t.name, err) + msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow())) + } + + case msg := <-t.serverMsg: + if msg.Info != nil || msg.Pres != nil { + globals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess) + } else { + // FIXME: should something be done here? + logs.Err.Printf("ERROR!!! topic proxy[%s]: unexpected server-side message in proxy topic %s", t.name, msg.describe()) + } + + case msg := <-t.meta: + // Request to get/set topic metadata + if err := globals.cluster.routeToTopicMaster(ProxyReqMeta, msg, t.name, msg.sess); err != nil { + logs.Warn.Printf("proxy topic[%s]: route meta request from proxy to master failed - %s", t.name, err) + msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow())) + } + + case upd := <-t.supd: + // Either an update to 'me' user agent from one of the sessions or + // background session comes to foreground. + req := ProxyReqMeUserAgent + tmpSess := &Session{tenantID: t.tenantID, userAgent: upd.userAgent} + if upd.sess != nil { + // Subscribed user may not match session user. Find out who is subscribed + pssd, ok := t.sessions[upd.sess] + if !ok { + logs.Warn.Printf("proxy topic[%s]: sess update request from detached session - sid %s", t.name, upd.sess.sid) + continue + } + req = ProxyReqBgSession + tmpSess.uid = pssd.uid + tmpSess.sid = upd.sess.sid + tmpSess.userAgent = upd.sess.userAgent + } + if err := globals.cluster.routeToTopicMaster(req, nil, t.name, tmpSess); err != nil { + logs.Warn.Printf("proxy topic[%s]: route sess update request from proxy to master failed - %s", t.name, err) + } + + case msg := <-t.proxy: + t.proxyMasterResponse(msg, killTimer) + + case sd := <-t.exit: + // Tell sessions to remove the topic + for s := range t.sessions { + s.detachSession(t.name) + } + + if err := globals.cluster.topicProxyGone(t.tenantID, t.name); err != nil { + logs.Warn.Printf("proxy topic[%s] shutdown: failed to notify master - %s", t.name, err) + } + + // Report completion back to sender, if 'done' is not nil. + if sd.done != nil { + sd.done <- true + } + return + + case <-killTimer.C: + // Topic timeout + hub.unreg <- &topicUnreg{tenantID: t.tenantID, rcptTo: t.name} + } + } +} + +// Takes a session leave request, forwards it to the topic master and +// modifies the local state accordingly. +// Returns whether the operation was successful. +func (t *Topic) handleProxyLeaveRequest(msg *ClientComMessage, killTimer *time.Timer) bool { + // Detach session from topic; session may continue to function. + var asUid types.Uid + if msg.init { + asUid = types.ParseUserId(msg.AsUser) + } + + if asUid.IsZero() { + if pssd, ok := t.sessions[msg.sess]; ok { + asUid = pssd.uid + } else { + logs.Warn.Printf("proxy topic[%s]: leave request sent for unknown session", t.name) + return false + } + } + // Remove the session from the topic without waiting for a response from the master node + // because by the time the response arrives this session may be already gone from the session store + // and we won't be able to find and remove it by its sid. + pssd, result := t.remSession(msg.sess, asUid) + if result { + msg.sess.delSub(t.name) + } + if !msg.init { + // Explicitly specify the uid because the master multiplex session needs to know which + // of its multiple hosted sessions to delete. + msg.AsUser = asUid.UserId() + msg.Leave = &MsgClientLeave{} + msg.init = true + } + // Make sure we set the Original field if it's empty (e.g. when session is terminating altogether). + if msg.Original == "" { + if t.cat == types.TopicCatGrp && t.isChan { + // It's a channel topic. Original topic name depends the subscription type. + if result && pssd.isChanSub { + msg.Original = types.GrpToChn(t.xoriginal) + } else { + msg.Original = t.xoriginal + } + } else { + msg.Original = t.original(asUid) + } + } + + if err := globals.cluster.routeToTopicMaster(ProxyReqLeave, msg, t.name, msg.sess); err != nil { + logs.Warn.Printf("proxy topic[%s]: route leave request from proxy to master failed - %s", t.name, err) + } + if len(t.sessions) == 0 { + // No more sessions attached. Start the countdown. + killTimer.Reset(idleProxyTopicTimeout) + } + return result +} + +// proxyMasterResponse at proxy topic processes a master topic response to an earlier request. +func (t *Topic) proxyMasterResponse(msg *ClusterResp, killTimer *time.Timer) { + // Kills topic after a period of inactivity. + keepAlive := idleProxyTopicTimeout + + if msg.SrvMsg.Pres != nil && msg.SrvMsg.Pres.What == "acs" && msg.SrvMsg.Pres.Acs != nil { + // If the server changed acs on this topic, update the internal state. + t.updateAcsFromPresMsg(msg.SrvMsg.Pres) + } + + if msg.OrigSid == "*" { + // It is a broadcast. + switch { + case msg.SrvMsg.Pres != nil || msg.SrvMsg.Data != nil || msg.SrvMsg.Info != nil: + // Regular broadcast. + t.handleProxyBroadcast(msg.SrvMsg) + case msg.SrvMsg.Ctrl != nil: + // Ctrl broadcast. E.g. for user eviction. + t.proxyCtrlBroadcast(msg.SrvMsg) + default: + } + } else { + sess := globals.sessionStore.Get(msg.OrigSid) + if sess == nil { + logs.Warn.Printf("proxy topic[%s]: session %s not found; already terminated?", t.name, msg.OrigSid) + } + switch msg.OrigReqType { + case ProxyReqJoin: + if sess != nil && msg.SrvMsg.Ctrl != nil { + // TODO: do we need to let the master topic know that the subscription is not longer valid + // or is it already informed by the session when it terminated? + + // Subscription result. + if msg.SrvMsg.Ctrl.Code < 300 { + sess.sessionStoreLock.Lock() + // Make sure the session isn't gone yet. + if session := globals.sessionStore.Get(msg.OrigSid); session != nil { + // Successful subscriptions. + t.addSession(session, msg.SrvMsg.uid, types.IsChannel(msg.SrvMsg.Ctrl.Topic)) + session.addSub(t.name, &Subscription{ + broadcast: t.clientMsg, + done: t.unreg, + meta: t.meta, + supd: t.supd, + }) + } + sess.sessionStoreLock.Unlock() + + killTimer.Stop() + } else if len(t.sessions) == 0 { + killTimer.Reset(keepAlive) + } + } + case ProxyReqBroadcast, ProxyReqMeta, ProxyReqCall: + // no processing + case ProxyReqLeave: + if msg.SrvMsg != nil && msg.SrvMsg.Ctrl != nil { + if msg.SrvMsg.Ctrl.Code < 300 { + if sess != nil { + t.remSession(sess, sess.uid) + } + } + // All sessions are gone. Start the kill timer. + if len(t.sessions) == 0 { + killTimer.Reset(keepAlive) + } + } + + default: + logs.Err.Printf("proxy topic[%s] received response referencing unexpected request type %d", + t.name, msg.OrigReqType) + } + + if sess != nil && !sess.queueOut(msg.SrvMsg) { + logs.Err.Printf("proxy topic[%s]: timeout in sending response - sid %s", t.name, sess.sid) + } + } +} + +// handleProxyBroadcast broadcasts a Data, Info or Pres message to sessions attached to this proxy topic. +func (t *Topic) handleProxyBroadcast(msg *ServerComMessage) { + if t.isInactive() { + // Ignore broadcast - topic is paused or being deleted. + return + } + + if msg.Data != nil { + t.lastID = msg.Data.SeqId + } + + t.broadcastToSessions(msg) +} + +// proxyCtrlBroadcast broadcasts a ctrl command to certain sessions attached to this proxy topic. +func (t *Topic) proxyCtrlBroadcast(msg *ServerComMessage) { + if msg.Ctrl.Code == http.StatusResetContent && msg.Ctrl.Text == "evicted" { + // We received a ctrl command for evicting a user. + if msg.uid.IsZero() { + logs.Err.Panicf("proxy topic[%s]: proxy received evict message with empty uid", t.name) + } + for sess := range t.sessions { + // Proxy topic may only have ordinary sessions. No multiplexing or proxy sessions here. + if _, removed := t.remSession(sess, msg.uid); removed { + sess.detachSession(t.name) + if sess.sid != msg.SkipSid { + sess.queueOut(msg) + } + } + } + } +} + +// updateAcsFromPresMsg modifies user acs in Topic's perUser struct based on the data in `pres`. +func (t *Topic) updateAcsFromPresMsg(pres *MsgServerPres) { + uid := types.ParseUserId(pres.Src) + if uid.IsZero() { + if t.cat != types.TopicCatMe { + logs.Warn.Printf("proxy topic[%s]: received acs change for invalid user id '%s'", t.name, pres.Src) + } + return + } + + // If t.perUser[uid] does not exist, pud is initialized with blanks, otherwise it gets existing values. + pud := t.perUser[uid] + dacs := pres.Acs + if err := pud.modeWant.ApplyMutation(dacs.Want); err != nil { + logs.Warn.Printf("proxy topic[%s]: could not process acs change - want: %s", t.name, err) + return + } + if err := pud.modeGiven.ApplyMutation(dacs.Given); err != nil { + logs.Warn.Printf("proxy topic[%s]: could not process acs change - given: %s", t.name, err) + return + } + // Update existing or add new. + t.perUser[uid] = pud +} diff --git a/server/topic_test.go b/server/topic_test.go index e69de29..f2ad2fb 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -0,0 +1,3386 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/mock_store" + "github.com/tinode/chat/server/store/types" +) + +type responses struct { + messages []any +} + +// Test fixture. +type TopicTestHelper struct { + numUsers int + uids []types.Uid + + // Gomock controller. + ctrl *gomock.Controller + + // Sessions. + sessions []*Session + sessWg *sync.WaitGroup + // Per-session responses (i.e. what gets dumped into sessions' write loops). + results []*responses + + // Hub. + hub *Hub + // Messages captured from Hub.route channel on the per-user (RcptTo) basis. + hubMessages map[string][]*ServerComMessage + // For stopping hub loop. + hubDone chan bool + + // Topic. + topic *Topic + + // Mock objects. + mm *mock_store.MockMessagesPersistenceInterface + uu *mock_store.MockUsersPersistenceInterface + tt *mock_store.MockTopicsPersistenceInterface + ss *mock_store.MockSubsPersistenceInterface +} + +func (b *TopicTestHelper) finish() { + b.topic.killTimer.Stop() + b.topic.callEstablishmentTimer.Stop() + // Stop session write loops. + for _, s := range b.sessions { + close(s.send) + } + b.sessWg.Wait() + // Hub loop. + close(b.hub.routeSrv) + close(b.hub.routeCli) + <-b.hubDone +} + +func (b *TopicTestHelper) newSession(sid string, uid types.Uid) (*Session, *responses) { + s := &Session{ + tenantID: 1, + sid: sid, + uid: uid, + subs: make(map[string]*Subscription), + send: make(chan any, 10), + detach: make(chan string, 10), + } + r := &responses{} + b.sessWg.Add(1) + go s.testWriteLoop(r, b.sessWg) + return s, r +} + +func (b *TopicTestHelper) setUp(t *testing.T, numUsers int, cat types.TopicCat, topicName string, attachSessions bool) { + t.Helper() + b.numUsers = numUsers + b.uids = make([]types.Uid, numUsers) + for i := range numUsers { + // Can't use 0 as a valid uid. + b.uids[i] = types.Uid(i + 1) + } + + // Mocks. + b.ctrl = gomock.NewController(t) + b.mm = mock_store.NewMockMessagesPersistenceInterface(b.ctrl) + b.uu = mock_store.NewMockUsersPersistenceInterface(b.ctrl) + b.tt = mock_store.NewMockTopicsPersistenceInterface(b.ctrl) + b.ss = mock_store.NewMockSubsPersistenceInterface(b.ctrl) + store.Messages = b.mm + store.Users = b.uu + store.Topics = b.tt + store.Subs = b.ss + // Sessions. + b.sessions = make([]*Session, b.numUsers) + b.results = make([]*responses, b.numUsers) + b.sessWg = &sync.WaitGroup{} + for i := range b.sessions { + s, r := b.newSession(fmt.Sprintf("sid%d", i), b.uids[i]) + b.results[i] = r + b.sessions[i] = s + } + + // Hub. + b.hub = &Hub{ + routeCli: make(chan *ClientComMessage, 10), + routeSrv: make(chan *ServerComMessage, 10), + } + globals.hub = b.hub + b.hubMessages = make(map[string][]*ServerComMessage) + b.hubDone = make(chan bool) + go b.hub.testHubLoop(t, b.hubMessages, b.hubDone) + + // Topic. + pu := make(map[types.Uid]perUserData) + ps := make(map[*Session]perSessionData) + for i, uid := range b.uids { + puData := perUserData{ + modeWant: types.ModeCFull, + modeGiven: types.ModeCFull, + } + if cat == types.TopicCatP2P { + puData.topicName = b.uids[i^1].UserId() + } + if attachSessions { + ps[b.sessions[i]] = perSessionData{uid: uid} + puData.online = 1 + } + pu[uid] = puData + } + b.topic = &Topic{ + tenantID: 1, + name: topicName, + cat: cat, + status: topicStatusLoaded, + perUser: pu, + isProxy: false, + sessions: ps, + killTimer: time.NewTimer(time.Hour), + callEstablishmentTimer: time.NewTimer(time.Second), + } + if cat != types.TopicCatSys { + b.topic.accessAuth = getDefaultAccess(cat, true, false) + b.topic.accessAnon = getDefaultAccess(cat, true, false) + } + if cat == types.TopicCatMe { + b.topic.xoriginal = "me" + } + if cat == types.TopicCatGrp { + b.topic.xoriginal = topicName + b.topic.owner = b.uids[0] + } +} + +func (b *TopicTestHelper) tearDown() { + globals.hub = nil + store.Messages = nil + store.Users = nil + store.Topics = nil + store.Subs = nil + b.ctrl.Finish() +} + +func (s *Session) testWriteLoop(results *responses, wg *sync.WaitGroup) { + for msg := range s.send { + results.messages = append(results.messages, msg) + } + wg.Done() +} + +func (h *Hub) testHubLoop(t *testing.T, results map[string][]*ServerComMessage, done chan bool) { + t.Helper() + for msg := range h.routeSrv { + if msg.RcptTo == "" { + // Don't call t.Fatal from goroutine - instead send error info back + results["__ERROR__"] = []*ServerComMessage{{ + Ctrl: &MsgServerCtrl{ + Code: 500, + Text: "Hub.route received a message without addressee.", + }, + }} + done <- true + return + } + results[msg.RcptTo] = append(results[msg.RcptTo], msg) + } + done <- true +} + +func TestHandleBroadcastDataP2P(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true) + defer helper.tearDown() + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Original: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Content: "test", + NoEcho: true, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Message uid1 -> uid2. + for i, m := range helper.results { + if i == 0 { + if len(m.messages) != 0 { + t.Fatalf("Uid1: expected 0 messages, got %d", len(m.messages)) + } + } else { + if len(m.messages) != 1 { + t.Fatalf("Uid2: expected 1 messages, got %d", len(m.messages)) + } + r := m.messages[0].(*ServerComMessage) + if r.Data == nil { + t.Fatalf("Response[0] must have a ctrl message") + } + if r.Data.Topic != from { + t.Errorf("Response[0] topic: expected '%s', got '%s'", from, r.Data.Topic) + } + if r.Data.Content.(string) != "test" { + t.Errorf("Response[0] content: expected 'test', got '%s'", r.Data.Content.(string)) + } + if r.Data.From != from { + t.Errorf("Response[0] from: expected '%s', got '%s'", from, r.Data.From) + } + } + } + // Checking presence messages routed through the helper. + if len(helper.hubMessages) != 2 { + t.Fatal("Huhelper.route expected exactly two recipients routed via huhelper.") + } + for i, uid := range helper.uids { + if mm, ok := helper.hubMessages[uid.UserId()]; ok { + if len(mm) == 1 { + s := mm[0] + if s.Pres != nil { + p := s.Pres + if p.Topic != "me" { + t.Errorf("Uid %s: pres notify on topic is expected to be 'me', got %s", uid.UserId(), p.Topic) + } + if p.SkipTopic != "p2p-test" { + t.Errorf("Uid %s: pres skip topic is expected to be 'p2p-test', got %s", uid.UserId(), p.SkipTopic) + } + expectedSrc := helper.uids[i^1].UserId() + if p.Src != expectedSrc { + t.Errorf("Uid %s: pres.src expected: %s, found: %s", uid.UserId(), expectedSrc, p.Src) + } + } else { + t.Errorf("Uid %s: hub message expected to be {pres}.", uid.UserId()) + } + } else { + t.Errorf("Uid %s: expected 1 hub message, got %d.", uid.UserId(), len(mm)) + } + } else { + t.Errorf("Uid %s: no hub results found.", uid.UserId()) + } + } +} + +func TestHandleBroadcastCall(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true) + globals.iceServers = []iceServer{{Username: "dummy"}} + helper.topic.lastID = 5 + defer helper.tearDown() + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Original: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Head: map[string]any{"webrtc": "started"}, + Content: "test", + NoEcho: true, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + globals.iceServers = nil + + // Message uid1 -> uid2. + for i, m := range helper.results { + if i == 0 { + if len(m.messages) != 0 { + t.Fatalf("Uid1: expected 0 messages, got %d", len(m.messages)) + } + } else { + if len(m.messages) != 1 { + t.Fatalf("Uid2: expected 1 messages, got %d", len(m.messages)) + } + r := m.messages[0].(*ServerComMessage) + if r.Data == nil { + t.Fatalf("Response[0] must have a ctrl message") + } + if r.Data.Topic != from { + t.Errorf("Response[0] topic: expected '%s', got '%s'", from, r.Data.Topic) + } + if r.Data.Content.(string) != "test" { + t.Errorf("Response[0] content: expected 'test', got '%s'", r.Data.Content.(string)) + } + if r.Data.Head == nil || r.Data.Head["webrtc"].(string) != "started" { + t.Errorf("Response[0] head: expected {'webrtc': 'started'}', got '%s'", r.Data.Content.(string)) + } + if r.Data.From != from { + t.Errorf("Response[0] from: expected '%s', got '%s'", from, r.Data.From) + } + } + } + // Checking presence messages routed through the helper. + if len(helper.hubMessages) != 2 { + t.Fatal("Huhelper.route expected exactly two recipients routed via huhelper.") + } + for i, uid := range helper.uids { + if mm, ok := helper.hubMessages[uid.UserId()]; ok { + if len(mm) == 1 { + s := mm[0] + if s.Pres != nil { + p := s.Pres + if p.Topic != "me" { + t.Errorf("Uid %s: pres notify on topic is expected to be 'me', got %s", uid.UserId(), p.Topic) + } + if p.SkipTopic != "p2p-test" { + t.Errorf("Uid %s: pres skip topic is expected to be 'p2p-test', got %s", uid.UserId(), p.SkipTopic) + } + expectedSrc := helper.uids[i^1].UserId() + if p.Src != expectedSrc { + t.Errorf("Uid %s: pres.src expected: %s, found: %s", uid.UserId(), expectedSrc, p.Src) + } + } else { + t.Errorf("Uid %s: hub message expected to be {pres}.", uid.UserId()) + } + } else { + t.Errorf("Uid %s: expected 1 hub message, got %d.", uid.UserId(), len(mm)) + } + } else { + t.Errorf("Uid %s: no hub results found.", uid.UserId()) + } + } + if helper.topic.currentCall == nil { + t.Fatal("No call in progress") + } + if helper.topic.currentCall.seq != 6 { + t.Errorf("Call seq: expected 6, found %d.", helper.topic.currentCall.seq) + } + if len(helper.topic.currentCall.parties) != 1 { + t.Fatalf("Call parties: expected 1, found %d.", len(helper.topic.currentCall.parties)) + } + if p, ok := helper.topic.currentCall.parties[helper.sessions[0].sid]; ok { + if !p.isOriginator { + t.Error("Call party is not a call originator.") + } + if p.uid != helper.uids[0] { + t.Errorf("Call party wrong uid: expected %s, found %s.", helper.uids[0].UserId(), p.uid.UserId()) + } + } else { + t.Errorf("Call party for session %s not found.", helper.sessions[0].sid) + } +} + +func TestHandleBroadcastDataGroup(t *testing.T) { + topicName := "grp-test" + numUsers := 4 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + defer func() { + store.Messages = nil + helper.tearDown() + }() + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + + // User 3 isn't allowed to read. + pu3 := helper.topic.perUser[helper.uids[3]] + pu3.modeWant = types.ModeJoin | types.ModeWrite | types.ModePres + pu3.modeGiven = pu3.modeWant + helper.topic.perUser[helper.uids[3]] = pu3 + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Original: topicName, + Pub: &MsgClientPub{ + Topic: topicName, + Content: "test", + NoEcho: true, + }, + sess: helper.sessions[0], + } + + if helper.topic.lastID != 0 { + t.Errorf("Topic.lastID: expected 0, found %d", helper.topic.lastID) + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if helper.topic.lastID != 1 { + t.Errorf("Topic.lastID: expected 1, found %d", helper.topic.lastID) + } + // Message uid0 -> uid1, uid2, uid3. + // Uid0 is the sender. + if len(helper.results[0].messages) != 0 { + t.Fatalf("Uid0 is the sender: expected 0 messages, got %d", len(helper.results[0].messages)) + } + // Uid3 is not a topic reader. + if len(helper.results[3].messages) != 0 { + t.Fatalf("Uid3 isn't allowed to read messages: expected 0 messages, got %d", len(helper.results[3].messages)) + } + for i := 1; i < 3; i++ { + m := helper.results[i] + if len(m.messages) != 1 { + t.Fatalf("Uid%d: expected 1 messages, got %d", i, len(m.messages)) + } + r := m.messages[0].(*ServerComMessage) + if r.Data == nil { + t.Fatalf("Response[0] must have a ctrl message") + } + if r.Data.Topic != topicName { + t.Errorf("Response[0] topic: expected '%s', got '%s'", topicName, r.Data.Topic) + } + if r.Data.From != from { + t.Errorf("Response[0] from: expected '%s', got '%s'", from, r.Data.From) + } + if r.Data.Content.(string) != "test" { + t.Errorf("Response[0] content: expected 'test', got '%s'", r.Data.Content.(string)) + } + } + // Presence messages. + if len(helper.hubMessages) != 3 { + t.Fatal("Hubhelper.route expected exactly three recipients routed via huhelper.") + } + for i, uid := range helper.uids { + if i == 3 { + if _, ok := helper.hubMessages[uid.UserId()]; ok { + t.Errorf("Uid %s: not expected to receive pres notifications.", uid.UserId()) + } + continue + } + if mm, ok := helper.hubMessages[uid.UserId()]; ok { + if len(mm) == 1 { + s := mm[0] + if s.Pres != nil { + p := s.Pres + if p.Topic != "me" { + t.Errorf("Uid %s: pres notify on topic is expected to be 'me', got %s", uid.UserId(), p.Topic) + } + if p.SkipTopic != topicName { + t.Errorf("Uid %s: pres skip topic is expected to be 'p2p-test', got %s", uid.UserId(), p.SkipTopic) + } + if p.Src != topicName { + t.Errorf("Uid %s: pres.src expected: %s, found: %s", uid.UserId(), topicName, p.Src) + } + } else { + t.Errorf("Uid %s: hub message expected to be {pres}.", uid.UserId()) + } + } else { + t.Errorf("Uid %s: expected 1 hub message, got %d.", uid.UserId(), len(mm)) + } + } else { + t.Errorf("Uid %s: no hub results found.", uid.UserId()) + } + } +} + +func TestHandleBroadcastDataMissingWritePermission(t *testing.T) { + topicName := "p2p-test" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + + // Remove W permission for uid1. + uid1 := helper.uids[0] + pud := helper.topic.perUser[uid1] + pud.modeGiven = types.ModeRead | types.ModeJoin + helper.topic.perUser[uid1] = pud + + // Make test message. + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Original: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Content: "test", + }, + sess: helper.sessions[0], + } + + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Message uid1 -> uid2. + if len(helper.results[0].messages) == 1 { + em := helper.results[0].messages[0].(*ServerComMessage) + if em.Ctrl == nil { + t.Fatal("User 1 is expected to receive a ctrl message") + } + if em.Ctrl.Code < 400 || em.Ctrl.Code >= 500 { + t.Errorf("User1: expected ctrl.code 4xx, received %d", em.Ctrl.Code) + } + } else { + t.Errorf("User 1 is expected to receive one message vs %d received.", len(helper.results[0].messages)) + } + if len(helper.results[1].messages) != 0 { + t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) + } + // Checking presence messages routed through hubhelper. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastDataDbError(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() + + // DB returns an error. + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(types.ErrInternal, false) + + // Make test message. + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Content: "test", + }, + sess: helper.sessions[0], + } + + if helper.topic.lastID != 0 { + t.Errorf("Topic.lastID: expected 0, found %d", helper.topic.lastID) + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if helper.topic.lastID != 0 { + t.Errorf("Topic.lastID: expected to remain 0, found %d", helper.topic.lastID) + } + // Message uid1 -> uid2. + if len(helper.results[0].messages) == 1 { + em := helper.results[0].messages[0].(*ServerComMessage) + if em.Ctrl == nil { + t.Fatal("User 1 is expected to receive a ctrl message") + } + if em.Ctrl.Code < 500 || em.Ctrl.Code >= 600 { + t.Errorf("User1: expected ctrl.code 5xx, received %d", em.Ctrl.Code) + } + } else { + t.Errorf("User 1 is expected to receive one message vs %d received.", len(helper.results[0].messages)) + } + if len(helper.results[1].messages) != 0 { + t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) + } + // Checking presence messages routed through hubhelper. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastDataInactiveTopic(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() + + // Make test message. + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Content: "test", + }, + sess: helper.sessions[0], + } + + // Deactivate topic. + helper.topic.markDeleted() + + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Message uid1 -> uid2. + if len(helper.results[0].messages) == 1 { + em := helper.results[0].messages[0].(*ServerComMessage) + if em.Ctrl == nil { + t.Fatal("User 1 is expected to receive a ctrl message") + } + if em.Ctrl.Code < 500 || em.Ctrl.Code >= 600 { + t.Errorf("User1: expected ctrl.code 5xx, received %d", em.Ctrl.Code) + } + } else { + t.Errorf("User 1 is expected to receive one message vs %d received.", len(helper.results[0].messages)) + } + if len(helper.results[1].messages) != 0 { + t.Errorf("User 2 is not expected to receive any messages, %d received.", len(helper.results[1].messages)) + } + // Checking presence messages routed through hubhelper. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoP2P(t *testing.T) { + topicName := "usrP2P" + numUsers := 2 + readId := 8 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 8. + from := helper.uids[0] + to := helper.uids[1] + + helper.ss.EXPECT().Update(types.TenantID(1), topicName, from, map[string]any{"ReadSeqId": readId}).Return(nil) + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Note: &MsgClientNote{ + Topic: to.UserId(), + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Topic metadata. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != readId { + t.Errorf("perUser[%s].readID: expected %d, found %d.", from.UserId(), readId, actualReadId) + } + // Server messages. + if len(helper.results[0].messages) != 0 { + t.Errorf("Session 0 isn't expected to receive any messages. Received %d", len(helper.results[0].messages)) + } + if len(helper.results[1].messages) != 1 { + t.Fatalf("Session 1 is expected to receive exactly 1 message. Received %d", len(helper.results[1].messages)) + } + res := helper.results[1].messages[0].(*ServerComMessage) + if res.Info != nil { + info := res.Info + // Topic name will be fixed (to -> from). + if info.Topic != from.UserId() { + t.Errorf("Info.Topic: expected '%s', found '%s'", to.UserId(), info.Topic) + } + if info.From != from.UserId() { + t.Errorf("Info.From: expected '%s', found '%s'", from.UserId(), info.From) + } + if info.What != "read" { + t.Errorf("Info.What: expected 'read', found '%s'", info.What) + } + if info.SeqId != readId { + t.Errorf("Info.SeqId: expected %d, found %d", readId, info.SeqId) + } + } else { + t.Error("Session message is expected to contain `info` section.") + } + // Checking presence messages routed through hub helper. These are intended for offline sessions. + if len(helper.hubMessages) != 2 { + t.Fatalf("Hubhelper.route expected exactly two recipients routed via hubhelper. Found %d", len(helper.hubMessages)) + } + for i, uid := range helper.uids { + if routedMsgs, ok := helper.hubMessages[uid.UserId()]; ok { + expectedSrc := helper.uids[i^1].UserId() + for _, s := range routedMsgs { + if s.Info != nil { + // Info messages for offline sessions. + info := s.Info + if info.Topic != "me" { + t.Errorf("Uid %s: info.topic is expected to be 'me', got %s", uid.UserId(), info.Topic) + } + if info.Src != expectedSrc { + t.Errorf("Uid %s: info.src expected: %s, found: %s", uid.UserId(), expectedSrc, info.Src) + } + if info.What != "read" { + t.Error("info.what expected to be 'read'") + } + if info.SeqId != readId { + t.Errorf("info.seq: expected %d, found %d", readId, info.SeqId) + } + } else if s.Pres != nil { + // Pres messages for offline sessions. + pres := s.Pres + if pres.Topic != "me" { + t.Errorf("Uid %s: pres.topic is expected to be 'me', got %s", uid.UserId(), pres.Topic) + } + if pres.What != "read" { + t.Error("pres.what expected to be 'read'") + } + if pres.Src != expectedSrc { + t.Errorf("Uid %s: pres.src expected: %s, found: %s", uid.UserId(), expectedSrc, pres.Src) + } + if pres.SeqId != readId { + t.Errorf("pres.seq: expected %d, found %d", readId, pres.SeqId) + } + } else { + t.Error("Hub messages must be either `info` or `pres`.") + } + } + } else { + t.Errorf("Uid %s: no hub results found.", uid.UserId()) + } + } +} + +func TestHandleBroadcastInfoBogusNotification(t *testing.T) { + topicName := "usrP2P" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 11 + from := helper.uids[0] + to := helper.uids[1] + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: to.UserId(), + Note: &MsgClientNote{ + Topic: to.UserId(), + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Read id should not be updated. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 { + t.Errorf("perUser[%s].readID: expected 0, found %d.", from.UserId(), actualReadId) + } + // Server messages. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + + // Nothing should be routed through the hub. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoFilterOutRecvWithoutRPermission(t *testing.T) { + topicName := "usrP2P" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 8 + from := helper.uids[0] + to := helper.uids[1] + + // Revoke R permission from the sender. + pud := helper.topic.perUser[from] + pud.modeGiven = types.ModeWrite | types.ModeJoin + helper.topic.perUser[from] = pud + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: to.UserId(), + Note: &MsgClientNote{ + Topic: to.UserId(), + What: "recv", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Read id should not be updated. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 { + t.Errorf("perUser[%s].readID: expected 0, found %d.", from.UserId(), actualReadId) + } + // Server messages. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + + // Nothing should be routed through the hub. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoFilterOutKpWithoutWPermission(t *testing.T) { + topicName := "usrP2P" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 8 + from := helper.uids[0] + to := helper.uids[1] + + // Revoke W permission from the sender. + pud := helper.topic.perUser[from] + pud.modeGiven = types.ModeRead | types.ModeJoin + helper.topic.perUser[from] = pud + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: to.UserId(), + Note: &MsgClientNote{ + Topic: to.UserId(), + What: "kp", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Read id should not be updated. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 { + t.Errorf("perUser[%s].readID: expected 0, found %d.", from.UserId(), actualReadId) + } + // Server messages. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + + // Nothing should be routed through the hub. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoDuplicatedRead(t *testing.T) { + topicName := "usrP2P" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName /*attach=*/, true) + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 8 + from := helper.uids[0] + to := helper.uids[1] + + // Revoke R permission from the sender. + pud := helper.topic.perUser[from] + pud.readID = 8 + helper.topic.perUser[from] = pud + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: to.UserId(), + Note: &MsgClientNote{ + Topic: to.UserId(), + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Read id should not be updated. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 8 { + t.Errorf("perUser[%s].readID: expected 8, found %d.", from.UserId(), actualReadId) + } + // Server messages. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + + // Nothing should be routed through the hub. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoDbError(t *testing.T) { + topicName := "usrP2P" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 8 + from := helper.uids[0] + to := helper.uids[1] + + helper.ss.EXPECT().Update(types.TenantID(1), topicName, from, map[string]any{"ReadSeqId": readId}).Return(types.ErrInternal) + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: to.UserId(), + Note: &MsgClientNote{ + Topic: to.UserId(), + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Read id should not be updated. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 { + t.Errorf("perUser[%s].readID: expected 0, found %d.", from.UserId(), actualReadId) + } + // Server messages. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + + // Nothing should be routed through the hub. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoInvalidChannelAccess(t *testing.T) { + topicName := "grpTest" + chanName := "chnTest" + numUsers := 3 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + // This is not a channel. However, we will try to handle an info message where + // the topic is referenced as "chn". + helper.topic.isChan = false + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 8 + from := helper.uids[0] + for i := 1; i < numUsers; i++ { + uid := helper.uids[i] + pud := helper.topic.perUser[uid] + pud.modeGiven = types.ModeCChnReader + helper.topic.perUser[uid] = pud + } + + msg := &ClientComMessage{ + Original: chanName, + AsUser: from.UserId(), + Note: &MsgClientNote{ + Topic: chanName, + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Read id should not be updated. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 { + t.Errorf("perUser[%s].readID: expected 0, found %d.", from.UserId(), actualReadId) + } + // Server messages. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + // Nothing should be routed through the hub. + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastInfoChannelProcessing(t *testing.T) { + topicName := "grpTest" + chanName := "chnTest" + numUsers := 3 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + helper.topic.isChan = true + defer helper.tearDown() + // Pretend we have 10 messages. + helper.topic.lastID = 10 + // uid1 notifies uid2 that uid1 has read messages up to seqid 11. + readId := 8 + from := helper.uids[0] + for i := 1; i < numUsers; i++ { + uid := helper.uids[i] + pud := helper.topic.perUser[uid] + pud.modeGiven = types.ModeCChnReader + pud.isChan = true + helper.topic.perUser[uid] = pud + } + + helper.ss.EXPECT().Update(types.TenantID(1), chanName, from, map[string]any{"ReadSeqId": readId}).Return(nil) + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: chanName, + Note: &MsgClientNote{ + Topic: chanName, + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Topic metadata. + // We do not update read ids for channel topics. + if actualReadId := helper.topic.perUser[from].readID; actualReadId != 0 { + t.Errorf("perUser[%s].readID: expected 0, found %d.", from.UserId(), actualReadId) + } + // Server messages. Note messages aren't forwarded by channel topics. + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received.", i, numMessages) + } + } + + // Send a pres back to the sender. + if len(helper.hubMessages) != 1 { + t.Fatalf("Hubhelper.route did not expect any messages, however %d received.", len(helper.hubMessages)) + } + if mm, ok := helper.hubMessages[from.UserId()]; ok || len(mm) != 1 { + s := mm[0] + if s.Pres != nil { + p := s.Pres + if p.Topic != "me" { + t.Errorf("Uid %s: pres notify on topic is expected to be 'me', got %s", from.UserId(), p.Topic) + } + if p.SkipTopic != topicName { + t.Errorf("Uid %s: pres skip topic is expected to be '%s', got %s", from.UserId(), topicName, p.SkipTopic) + } + if p.Src != topicName { + t.Errorf("Uid %s: pres.src expected: %s, found: %s", from.UserId(), topicName, p.Src) + } + if p.What != "read" { + t.Errorf("Uid %s: pres.what expected: 'read', found: %s", from.UserId(), p.What) + } + } else { + t.Errorf("Uid %s: hub message expected to be {pres}.", from.UserId()) + } + } else { + t.Errorf("Uid %s: expected 1 hub message, got %d.", from.UserId(), len(mm)) + } +} + +func TestHandleBroadcastPresMe(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, true) + defer helper.tearDown() + + uid := helper.uids[0] + srcUid := types.Uid(10) + helper.topic.perSubs = make(map[string]perSubsData) + helper.topic.perSubs[srcUid.UserId()] = perSubsData{enabled: true, online: false} + + msg := &ServerComMessage{ + AsUser: uid.UserId(), + RcptTo: uid.UserId(), + Pres: &MsgServerPres{ + Topic: "me", + Src: srcUid.UserId(), + What: "on", + }, + } + helper.topic.handleServerMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Topic metadata. + if online := helper.topic.perSubs[srcUid.UserId()].online; !online { + t.Errorf("User %s is expected to be online.", srcUid.UserId()) + } + // Server messages. + if len(helper.results[0].messages) != 1 { + t.Fatalf("Session 0 is expected to receive one message. Received %d.", len(helper.results[0].messages)) + } + s := helper.results[0].messages[0].(*ServerComMessage) + if s.RcptTo != uid.UserId() { + t.Errorf("Message.RcptTo: expected '%s', found '%s'", uid.UserId(), s.RcptTo) + } + if s.Pres != nil { + pres := s.Pres + if pres.Topic != "me" { + t.Errorf("Expected to notify user on 'me' topic. Found: '%s'", pres.Topic) + } + if pres.Src != srcUid.UserId() { + t.Errorf("Expected notification from '%s'. Found: '%s'", srcUid.UserId(), pres.Topic) + } + if pres.What != "on" { + t.Errorf("Expected an online notification. Found: '%s'", pres.What) + } + } else { + t.Error("Message is expected to be pres.") + } + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route isn't expected to receive messages. Received %d", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastPresInactiveTopic(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, true) + defer helper.tearDown() + + uid := helper.uids[0] + srcUid := types.Uid(10) + helper.topic.perSubs = make(map[string]perSubsData) + helper.topic.perSubs[srcUid.UserId()] = perSubsData{enabled: true, online: false} + + msg := &ServerComMessage{ + AsUser: uid.UserId(), + RcptTo: uid.UserId(), + Pres: &MsgServerPres{ + Topic: "me", + Src: srcUid.UserId(), + What: "on", + }, + } + + // Deactivate topic. + helper.topic.markDeleted() + + helper.topic.handleServerMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Topic metadata. + if online := helper.topic.perSubs[srcUid.UserId()].online; online { + t.Errorf("User %s is expected to be offline.", srcUid.UserId()) + } + // Server messages. + if len(helper.results[0].messages) != 0 { + t.Fatalf("Session 0 is not expected to receive messages. Received %d.", len(helper.results[0].messages)) + } + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route isn't expected to receive messages. Received %d", len(helper.hubMessages)) + } +} + +const ( + NoSub = 0 + ExistingSubEnabled = 1 + ExistingSubDisabled = 2 +) + +func NoChangeInStatusTest(t *testing.T, subscriptionStatus int, what string) *TopicTestHelper { + t.Helper() + topicName := "usrMe" + numUsers := 1 + helper := &TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, true) + + uid := helper.uids[0] + srcUid := types.Uid(10) + helper.topic.perSubs = make(map[string]perSubsData) + enabled := false + switch subscriptionStatus { + case NoSub: + case ExistingSubEnabled: + enabled = true + fallthrough + case ExistingSubDisabled: + helper.topic.perSubs[srcUid.UserId()] = perSubsData{enabled: enabled, online: false} + } + + msg := &ServerComMessage{ + AsUser: uid.UserId(), + RcptTo: uid.UserId(), + Pres: &MsgServerPres{ + Topic: "me", + Src: srcUid.UserId(), + // No change in online status. + What: what, + }, + } + + helper.topic.handleServerMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Topic metadata. + if online := helper.topic.perSubs[srcUid.UserId()].online; online { + t.Errorf("User %s is expected to be offline.", srcUid.UserId()) + } + // Server messages. + if len(helper.results[0].messages) != 0 { + t.Fatalf("Session 0 is not expected to receive messages. Received %d.", len(helper.results[0].messages)) + } + if len(helper.hubMessages) != 0 { + t.Errorf("Hubhelper.route isn't expected to receive messages. Received %d", len(helper.hubMessages)) + } + return helper +} + +func TestHandleBroadcastPresUnkn(t *testing.T) { + NoChangeInStatusTest(t, ExistingSubEnabled, "?unkn").tearDown() +} + +func TestHandleBroadcastPresNone(t *testing.T) { + NoChangeInStatusTest(t, ExistingSubEnabled, "?none").tearDown() +} + +func TestHandleBroadcastPresRedundantUpdate(t *testing.T) { + h := NoChangeInStatusTest(t, ExistingSubDisabled, "off+rem") + uid := h.uids[0] + if _, ok := h.topic.perSubs[uid.UserId()]; ok { + t.Errorf("Subscription for user %s expected to be deleted.", uid.UserId()) + } + h.tearDown() +} + +func TestHandleBroadcastPresNewSub(t *testing.T) { + NoChangeInStatusTest(t, NoSub, "off+wrong").tearDown() +} + +func TestHandleBroadcastPresUnknownSub(t *testing.T) { + NoChangeInStatusTest(t, NoSub, "on+rem").tearDown() +} + +func TestReplyGetDescInvalidOpts(t *testing.T) { + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, "" /*attach=*/, true) + defer helper.tearDown() + + msg := ClientComMessage{ + Original: "dummy", + } + // Can't specify User in opts. + if err := helper.topic.replyGetDesc(helper.sessions[0], 123, false, &MsgGetOpts{User: "abcdef"}, &msg); err == nil { + t.Error("replyGetDesc expected to error out.") + } else if err.Error() != "invalid GetDesc query" { + t.Errorf("Unexpected error: expected 'invalid GetDesc query', got '%s'", err.Error()) + } + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.results[0].messages) != 1 { + t.Fatalf("`responses` expected to contain 1 element, found %d", len(helper.results[0].messages)) + } + resp := helper.results[0].messages[0].(*ServerComMessage) + if resp.Ctrl == nil { + t.Fatalf("response expected to contain a Ctrl message") + } + if resp.Ctrl.Code != 400 { + t.Errorf("response code: expected 400, found: %d", resp.Ctrl.Code) + } + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +// Verifies ctrl codes in session outputs. +func registerSessionVerifyOutputs(t *testing.T, sessionOutput *responses, expectedCtrlCodes []int) { + t.Helper() + // Session output. + if len(sessionOutput.messages) == len(expectedCtrlCodes) { + n := len(expectedCtrlCodes) + for i := range n { + resp := sessionOutput.messages[i].(*ServerComMessage) + code := expectedCtrlCodes[i] + if resp.Ctrl != nil { + if resp.Ctrl.Code != code { + t.Errorf("response code: expected %d, found: %d", code, resp.Ctrl.Code) + } + } else { + t.Errorf("response %d: expected to contain a Ctrl message", i) + } + } + } else { + t.Errorf("Session output: expected %d responses, received %d", len(expectedCtrlCodes), + len(sessionOutput.messages)) + } +} + +func TestRegisterSessionMe(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + uid := helper.uids[0] + + // Add a couple more sessions. + for i := 1; i < 3; i++ { + s, r := helper.newSession(fmt.Sprintf("sid%d", i), uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + } + + for i, s := range helper.sessions { + join := &ClientComMessage{ + Sub: &MsgClientSub{ + Id: fmt.Sprintf("id456-%d", i), + Topic: "me", + }, + AsUser: uid.UserId(), + sess: s, + } + helper.topic.registerSession(join) + } + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 3 { + t.Errorf("Attached sessions: expected 3, found %d", len(helper.topic.sessions)) + } + for _, s := range helper.sessions { + if len(s.subs) != 1 { + t.Errorf("Session subscriptions: expected 3, found %d", len(s.subs)) + } + } + online := helper.topic.perUser[uid].online + if online != 3 { + t.Errorf("Number of online sessions: expected 3, found %d", online) + } + // Session output. + for _, r := range helper.results { + registerSessionVerifyOutputs(t, r, []int{http.StatusOK}) + } + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionInactiveTopic(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + uid := helper.uids[0] + + s := helper.sessions[0] + join := &ClientComMessage{ + Sub: &MsgClientSub{ + Id: "id456", + Topic: "me", + }, + AsUser: uid.UserId(), + sess: s, + } + + // Deactivate topic. + helper.topic.markDeleted() + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, helper.results[0], []int{http.StatusServiceUnavailable}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionUserSpecifiedInSetMessage(t *testing.T) { + topicName := "grpTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + uid := helper.uids[0] + + s := helper.sessions[0] + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + // Specify the user. This should result in an error. + User: "foo", + }, + }, + }, + AsUser: uid.UserId(), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, helper.results[0], []int{http.StatusBadRequest}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionInvalidWantStrInSetMessage(t *testing.T) { + topicName := "grpTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + uid := helper.uids[0] + + s := helper.sessions[0] + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + // Specify the user. This should result in an error. + Mode: "Invalid mode string", + }, + }, + }, + AsUser: uid.UserId(), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, helper.results[0], []int{http.StatusBadRequest}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionMaxSubscriberCountExceeded(t *testing.T) { + topicName := "grpTest" + // Pretend we already exceeded the maximum user count. This should produce an error. + numUsers := 10 + oldMaxSubscribers := globals.maxSubscriberCount + globals.maxSubscriberCount = 10 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer func() { + helper.tearDown() + globals.maxSubscriberCount = oldMaxSubscribers + }() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + // New uid. This should attempt to add a new subscription. + uid := types.Uid(10001) + s, r := helper.newSession("test-sid", uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + }, + AsUser: uid.UserId(), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusUnprocessableEntity}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionLowAuthLevelWithSysTopic(t *testing.T) { + topicName := "sys" + // No one is subscribed to sys. + numUsers := 0 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatSys, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + // New uid. This should attempt to add a new subscription + // which produces an error b/c authLevel isn't root. + uid := types.Uid(10001) + s, r := helper.newSession("test-sid", uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + }, + AsUser: uid.UserId(), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusForbidden}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionNewChannelGetSubDbError(t *testing.T) { + topicName := "grpTest" + chanName := "chnTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + // It is a channel. + helper.topic.isChan = true + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + // New uid. This should attempt to add a new subscription + // which produces an error b/c authLevel isn't root. + uid := types.Uid(10001) + s, r := helper.newSession("test-sid", uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + + join := &ClientComMessage{ + Original: chanName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: chanName, + }, + AsUser: uid.UserId(), + sess: s, + } + + helper.ss.EXPECT().Get(types.TenantID(1), chanName, uid, false).Return(nil, types.ErrInternal) + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionCreateSubFailed(t *testing.T) { + topicName := "grpTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + // New uid. This should attempt to add a new subscription + // which produces an error b/c authLevel isn't root. + uid := types.Uid(10001) + s, r := helper.newSession("test-sid", uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + sess: s, + } + + helper.ss.EXPECT().Get(types.TenantID(1), topicName, uid, true).Return(nil, types.ErrInternal) + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionAsChanUserNotChanSubcriber(t *testing.T) { + topicName := "grpTest" + chanName := "chnTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + // The topic is a channel. + helper.topic.isChan = true + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + s := helper.sessions[0] + uid := helper.uids[0] + r := helper.results[0] + + // User is not a channel subscriber (userData.isChan is false). + join := &ClientComMessage{ + Original: chanName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: chanName, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. Tell the subscriber to use non-channel name. + registerSessionVerifyOutputs(t, r, []int{http.StatusSeeOther}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionOwnerBansHimself(t *testing.T) { + topicName := "grpTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + s := helper.sessions[0] + uid := helper.uids[0] + r := helper.results[0] + + // User is the topic owner. + helper.topic.owner = uid + pud := helper.topic.perUser[uid] + pud.modeGiven |= types.ModeOwner + helper.topic.perUser[uid] = pud + + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + // No O permission. + Mode: "JPRW", + }, + }, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusForbidden}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionInvalidOwnershipTransfer(t *testing.T) { + topicName := "grpTest" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + s := helper.sessions[1] + uid := helper.uids[1] + r := helper.results[1] + + // User is the topic owner. + pud := helper.topic.perUser[uid] + pud.modeWant = types.ModeCPublic + pud.modeGiven = types.ModeCPublic + helper.topic.perUser[uid] = pud + + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + // Want ownership. + Mode: "JPRWSO", + }, + }, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + sess: s, + } + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusForbidden}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionMetadataUpdateFails(t *testing.T) { + topicName := "grpTest" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + s := helper.sessions[1] + uid := helper.uids[1] + r := helper.results[1] + + pud := helper.topic.perUser[uid] + pud.modeWant = types.ModeCPublic + pud.modeGiven = types.ModeCPublic + helper.topic.perUser[uid] = pud + + // Want ownership. + newWant := "JRWP" + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + Mode: newWant, + }, + }, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + + sess: s, + } + // DB call fails. + helper.ss.EXPECT().Update(types.TenantID(1), topicName, uid, gomock.Any()).Return(types.ErrInternal) + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestRegisterSessionOwnerChangeDbCallFails(t *testing.T) { + topicName := "grpTest" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + if len(helper.topic.sessions) != 0 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 0 vs found %d", len(helper.topic.sessions)) + } + + s := helper.sessions[0] + uid := helper.uids[0] + r := helper.results[0] + + // User is the topic owner. + pud := helper.topic.perUser[uid] + pud.modeWant = types.ModeCPublic + helper.topic.perUser[uid] = pud + + // Want ownership. + newWant := "JRWPASO" + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + Mode: newWant, + }, + }, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + sess: s, + } + helper.ss.EXPECT().Update(types.TenantID(1), topicName, uid, gomock.Any()).Return(nil).Times(2) + // OwnerChange call fails. + helper.tt.EXPECT().OwnerChange(types.TenantID(1), topicName, uid).Return(types.ErrInternal) + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 0 { + t.Errorf("Number of online sessions: expected 0, found %d", online) + } + registerSessionVerifyOutputs(t, r, []int{}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestUnregisterSessionSimple(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + uid := helper.uids[0] + helper.uu.EXPECT().UpdateLastSeen(types.TenantID(1), uid, gomock.Any(), gomock.Any()).Return(nil) + + // Add a couple more sessions. + for i := 1; i < 3; i++ { + s, r := helper.newSession(fmt.Sprintf("sid%d", i), uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + helper.topic.sessions[s] = perSessionData{uid: uid} + pu := helper.topic.perUser[uid] + pu.online++ + helper.topic.perUser[uid] = pu + } + + // Initial online and attach session counts. + if len(helper.topic.sessions) != 3 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 3 vs found %d", len(helper.topic.sessions)) + } + if online := helper.topic.perUser[uid].online; online != 3 { + t.Errorf("Number of online sessions: expected 3 vs found %d", online) + } + + s := helper.sessions[0] + r := helper.results[0] + leave := &ClientComMessage{ + Leave: &MsgClientLeave{ + Id: "id456", + Topic: topicName, + }, + AsUser: uid.UserId(), + sess: s, + init: true, + } + helper.topic.unregisterSession(leave) + + helper.finish() + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 2 { + t.Errorf("Attached sessions: expected 2, found %d", len(helper.topic.sessions)) + } + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(helper.sessions[0].subs)) + } + if online := helper.topic.perUser[uid].online; online != 2 { + t.Errorf("Number of online sessions after unregistering: expected 2, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusOK}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestUnregisterSessionInactiveTopic(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, true) + defer helper.tearDown() + + uid := helper.uids[0] + + // Initial online and attach session counts. + if len(helper.topic.sessions) != 1 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 1 vs found %d", len(helper.topic.sessions)) + } + if online := helper.topic.perUser[uid].online; online != 1 { + t.Errorf("Number of online sessions: expected 1 vs found %d", online) + } + + s := helper.sessions[0] + r := helper.results[0] + leave := &ClientComMessage{ + Leave: &MsgClientLeave{ + Id: "id456", + Topic: topicName, + }, + AsUser: uid.UserId(), + sess: s, + init: true, + } + + // Deactivate topic. + helper.topic.markDeleted() + + helper.topic.unregisterSession(leave) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 1 { + t.Errorf("Attached sessions: expected 1, found %d", len(helper.topic.sessions)) + } + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + if online := helper.topic.perUser[uid].online; online != 1 { + t.Errorf("Number of online sessions after unregistering: expected 1, found %d", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusServiceUnavailable}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub isn't expected to receive any messages, received %d", len(helper.hubMessages)) + } +} + +func TestUnregisterSessionUnsubscribe(t *testing.T) { + topicName := "grpTest" + numUsers := 3 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + defer helper.tearDown() + + uid := helper.uids[2] + helper.ss.EXPECT().Delete(types.TenantID(1), topicName, uid).Return(nil) + + // Add a couple more sessions. + for i := range 2 { + s, r := helper.newSession(fmt.Sprintf("sid-uid-%d-%d", uid, i), uid) + helper.sessions = append(helper.sessions, s) + helper.results = append(helper.results, r) + helper.topic.sessions[s] = perSessionData{uid: uid} + pu := helper.topic.perUser[uid] + pu.online++ + helper.topic.perUser[uid] = pu + } + + // Initial online and attach session counts. + if len(helper.topic.sessions) != 5 { + helper.finish() + t.Fatalf("Initially attached sessions: expected 5 vs found %d", len(helper.topic.sessions)) + } + if online := helper.topic.perUser[uid].online; online != 3 { + t.Errorf("Number of online sessions: expected 3 vs found %d", online) + } + + leave := &ClientComMessage{ + Leave: &MsgClientLeave{ + Id: "id456", + Topic: topicName, + Unsub: true, + }, + AsUser: uid.UserId(), + sess: helper.sessions[0], + init: true, + } + helper.topic.unregisterSession(leave) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 2 { + t.Errorf("Attached sessions: expected 2, found %d", len(helper.topic.sessions)) + } + if len(helper.sessions[0].subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(helper.sessions[0].subs)) + } + if pu, ok := helper.topic.perUser[uid]; pu.online != 0 || ok { + t.Errorf("Number of online sessions after unsubscribing: expected 2, found %d; perUser entry found: %t", pu.online, ok) + } + // Session output. Sessions 2, 3, 4 are the evicted/unsubscribed uid. + for i := 2; i < 5; i++ { + r := helper.results[i] + registerSessionVerifyOutputs(t, r, []int{http.StatusResetContent}) + } + // Presence notifications. + if len(helper.hubMessages) != 2 { + t.Errorf("Hub messages recipients: expected 2, received %d", len(helper.hubMessages)) + } + // Group presSubs. + if grpPres, ok := helper.hubMessages[topicName]; ok { + if len(grpPres) != 2 { + t.Fatalf("Group presence messages: expected 2, got %d", len(grpPres)) + } + for _, msg := range grpPres { + // + pres := msg.Pres + if pres == nil { + t.Fatal("Presence message expected in hub output, but not found.") + } + if pres.Topic != topicName { + t.Errorf("Presence message topic: expected %s, found %s", topicName, pres.Topic) + } + if pres.Src != uid.UserId() { + t.Errorf("Presence message src: expected %s, found %s", uid.UserId(), pres.Src) + } + if pres.What != "acs" && pres.What != "off" { + t.Errorf("Presence message what: expected 'acs' or 'off', found %s", pres.What) + } + } + } else { + t.Errorf("Hub expected to pres recipient %s", topicName) + } + // User notification. + if userPres, ok := helper.hubMessages[uid.UserId()]; ok { + if len(userPres) != 1 { + t.Fatalf("User presence messages: expected 1, got %d", len(userPres)) + } + pres := userPres[0].Pres + if pres == nil { + t.Fatal("Presence message expected in hub output, but not found.") + } + if pres.Topic != "me" { + t.Errorf("Presence message topic: expected 'me', found %s", pres.Topic) + } + if pres.Src != topicName { + t.Errorf("Presence message src: expected %s, found %s", topicName, pres.Src) + } + if pres.What != "gone" { + t.Errorf("Presence message what: expected 'gone', found %s", pres.What) + } + } else { + t.Errorf("Hub expected to pres recipient %s", uid.UserId()) + } +} + +func TestUnregisterSessionOwnerCannotUnsubscribe(t *testing.T) { + topicName := "grpTest" + numUsers := 3 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + defer helper.tearDown() + + uid := helper.uids[0] + s := helper.sessions[0] + r := helper.results[0] + + leave := &ClientComMessage{ + Leave: &MsgClientLeave{ + Id: "id456", + Topic: topicName, + Unsub: true, + }, + AsUser: uid.UserId(), + sess: s, + init: true, + } + + helper.topic.unregisterSession(leave) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 3 { + t.Errorf("Attached sessions: expected 3, found %d", len(helper.topic.sessions)) + } + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(helper.sessions[0].subs)) + } + if online := helper.topic.perUser[uid].online; online != 1 { + t.Errorf("Number of online sessions after failed unsubscribing: expected 1, found %d.", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusForbidden}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub messages recipients: expected 0, received %d", len(helper.hubMessages)) + } +} + +func TestUnregisterSessionUnsubDeleteCallFails(t *testing.T) { + topicName := "grpTest" + numUsers := 3 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + defer helper.tearDown() + + // Unsubscribe user 1 (cannot unsub user 0, the owner). + uid := helper.uids[1] + s := helper.sessions[1] + r := helper.results[1] + + leave := &ClientComMessage{ + Leave: &MsgClientLeave{ + Id: "id456", + Topic: topicName, + Unsub: true, + }, + AsUser: uid.UserId(), + sess: s, + init: true, + } + // DB call fails. + helper.ss.EXPECT().Delete(types.TenantID(1), topicName, uid).Return(types.ErrInternal) + + helper.topic.unregisterSession(leave) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 3 { + t.Errorf("Attached sessions: expected 3, found %d", len(helper.topic.sessions)) + } + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(helper.sessions[0].subs)) + } + if online := helper.topic.perUser[uid].online; online != 1 { + t.Errorf("Number of online sessions after failed unsubscribing: expected 1, found %d.", online) + } + // Session output. + registerSessionVerifyOutputs(t, r, []int{http.StatusInternalServerError}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub messages recipients: expected 0, received %d", len(helper.hubMessages)) + } +} + +func TestHandleMetaChanErr(t *testing.T) { + topicName := "grpTest" + chanName := "chnTest" + numUsers := 3 + helper := TopicTestHelper{} + defer helper.tearDown() + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + + // This is not a channel. However, we will try to handle an info message where + // the topic is referenced as "chn". + helper.topic.isChan = false + // Empty message since this request should trigger an error anyway. + meta := &ClientComMessage{ + AsUser: helper.uids[0].UserId(), + Original: chanName, + MetaWhat: constMsgMetaDesc | constMsgMetaSub | constMsgMetaData | constMsgMetaDel, + sess: helper.sessions[0], + } + helper.topic.handleMeta(meta) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Session output. + registerSessionVerifyOutputs(t, helper.results[0], []int{http.StatusNotFound}) + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub messages recipients: expected 0, received %d", len(helper.hubMessages)) + } +} + +func TestHandleMetaGet(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName, true) + defer helper.tearDown() + + uid := helper.uids[0] + helper.mm.EXPECT().GetAll(types.TenantID(1), topicName, uid, gomock.Any()).Return([]types.Message{}, nil) + helper.mm.EXPECT().GetDeleted(types.TenantID(1), topicName, uid, gomock.Any()).Return([]types.Range{}, 0, nil) + helper.uu.EXPECT().GetTopics(types.TenantID(1), uid, gomock.Any()).Return([]types.Subscription{}, nil) + + meta := &ClientComMessage{ + Get: &MsgClientGet{ + Id: "id456", + Topic: topicName, + MsgGetQuery: MsgGetQuery{ + What: "desc sub data del", + Desc: &MsgGetOpts{}, + Sub: &MsgGetOpts{}, + Data: &MsgGetOpts{}, + Del: &MsgGetOpts{}, + }, + }, + AsUser: uid.UserId(), + MetaWhat: constMsgMetaDesc | constMsgMetaSub | constMsgMetaData | constMsgMetaDel, + sess: helper.sessions[0], + } + helper.topic.handleMeta(meta) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + r := helper.results[0] + if len(r.messages) != 4 { + t.Errorf("responses received: expected 4, received %d", len(r.messages)) + } + for _, msg := range r.messages { + m := msg.(*ServerComMessage) + if m.Meta != nil { + if m.Meta.Desc == nil { + t.Error("Meta.Desc expected to be specified.") + } + } else if m.Ctrl == nil { + t.Error("Expected only meta or ctrl messages.") + } + } + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Errorf("Hub messages recipients: expected 0, received %d", len(helper.hubMessages)) + } +} + +// Matches a subset in a superset. +type supersetOf struct{ subset map[string]string } + +func SupersetOf(subset map[string]string) gomock.Matcher { + return &supersetOf{subset} +} + +func (s *supersetOf) Matches(x any) bool { + super := x.(map[string]any) + if super == nil { + return false + } + for k, v := range s.subset { + if x, ok := super[k]; ok { + val := x.(string) + if val != v { + return false + } + } else { + return false + } + } + return true +} + +func (s *supersetOf) String() string { + return fmt.Sprintf("%+v is subset", s.subset) +} + +func TestHandleMetaSetDescMePublicPrivate(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + uid := helper.uids[0] + gomock.InOrder( + helper.uu.EXPECT().Update(types.TenantID(1), uid, SupersetOf(map[string]string{"Public": "new public"})).Return(nil), + helper.ss.EXPECT().Update(types.TenantID(1), topicName, uid, map[string]any{"Private": "new private"}).Return(nil), + ) + + meta := &ClientComMessage{ + Set: &MsgClientSet{ + Id: "id456", + Topic: topicName, + MsgSetQuery: MsgSetQuery{ + Desc: &MsgSetDesc{ + Public: "new public", + Private: "new private", + }, + }, + }, + AsUser: uid.UserId(), + MetaWhat: constMsgMetaDesc, + sess: helper.sessions[0], + } + helper.topic.handleMeta(meta) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + r := helper.results[0] + if len(r.messages) != 1 { + t.Fatalf("responses received: expected 1, received %d", len(r.messages)) + } + msg := r.messages[0].(*ServerComMessage) + if msg == nil || msg.Ctrl == nil { + t.Fatalf("Server message expected to have a ctrl submessage: %+v", msg) + } + if msg.Ctrl.Code != 200 { + t.Errorf("Response code: expected 200, found %d", msg.Ctrl.Code) + } + // Presence notifications. + if len(helper.hubMessages) != 1 { + t.Fatalf("Hub messages recipients: expected 1, received %d", len(helper.hubMessages)) + } + // Make sure uid's sessions are notified. + if userPres, ok := helper.hubMessages[uid.UserId()]; ok { + if len(userPres) != 1 { + t.Fatalf("User presence messages: expected 1, got %d", len(userPres)) + } + if userPres[0].SkipSid != helper.sessions[0].sid { + t.Errorf("Pres notification SkipSid: %s expected vs %s found", helper.sessions[0].sid, userPres[0].SkipSid) + } + pres := userPres[0].Pres + if pres == nil { + t.Fatal("Presence message expected in hub output, but not found.") + } + if pres.Topic != "me" { + t.Errorf("Presence message topic: expected 'me', found %s", pres.Topic) + } + if pres.What != "upd" { + t.Errorf("Presence message what: expected 'upd', found %s", pres.What) + } + } else { + t.Errorf("Hub expected to pres recipient %s", uid.UserId()) + } +} + +func TestHandleSessionUpdateSessToForeground(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + uid := helper.uids[0] + supd := &sessionUpdate{ + sess: helper.sessions[0], + } + var uaAgent string + helper.topic.handleSessionUpdate(supd, &uaAgent, nil) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Expect online count bumped up to 2. + if online := helper.topic.perUser[uid].online; online != 2 { + t.Errorf("online count for %s: expected 2, found %d", uid.UserId(), online) + } +} + +func TestHandleSessionUpdateUserAgent(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + uid := helper.uids[0] + supd := &sessionUpdate{ + userAgent: "newUA", + } + uaAgent := "oldUA" + timer := time.NewTimer(time.Hour) + helper.topic.handleSessionUpdate(supd, &uaAgent, timer) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // online count stays 1. + if online := helper.topic.perUser[uid].online; online != 1 { + t.Errorf("online count for %s: expected 1, found %d", uid.UserId(), online) + } + if uaAgent != "newUA" { + t.Errorf("User agent: expected 'newUA', found '%s'", uaAgent) + } + timer.Stop() +} + +func TestHandleUATimerEvent(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + uid := helper.uids[0] + helper.topic.perSubs = make(map[string]perSubsData) + helper.topic.perSubs[uid.UserId()] = perSubsData{online: true} + helper.topic.handleUATimerEvent("newUA") + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if helper.topic.userAgent != "newUA" { + t.Errorf("Topic's user agent: expected 'newUA', found '%s'", helper.topic.userAgent) + } + // Presence notifications. + if len(helper.hubMessages) != 1 { + t.Fatalf("Hub messages recipients: expected 1, received %d", len(helper.hubMessages)) + } + // Make sure uid's sessions are notified. + if userPres, ok := helper.hubMessages[uid.UserId()]; ok { + if len(userPres) != 1 { + t.Fatalf("User presence messages: expected 1, got %d", len(userPres)) + } + pres := userPres[0].Pres + if pres == nil { + t.Fatal("Presence message expected in hub output, but not found.") + } + if pres.Topic != "me" { + t.Errorf("Presence message topic: expected 'me', found '%s'", pres.Topic) + } + if pres.What != "ua" { + t.Errorf("Presence message what: expected 'ua', found '%s'", pres.What) + } + if pres.Src != topicName { + t.Errorf("Presence message src: expected '%s', found '%s'", topicName, pres.Src) + } + } else { + t.Errorf("Hub expected to pres recipient %s", uid.UserId()) + } +} + +func TestHandleTopicTimeout(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + uid := helper.uids[0] + helper.topic.perSubs = make(map[string]perSubsData) + helper.topic.perSubs[uid.UserId()] = perSubsData{online: true} + helper.hub.unreg = make(chan *topicUnreg, 10) + uaTimer := time.NewTimer(time.Hour) + notifTimer := time.NewTimer(time.Hour) + helper.topic.handleTopicTimeout(helper.hub, "newUA", uaTimer, notifTimer) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.hub.unreg) != 1 { + t.Fatalf("Hub.unreg chan must contain exactly 1 message. Found %d.", len(helper.hub.unreg)) + } + if unreg := <-helper.hub.unreg; unreg.rcptTo != topicName { + t.Errorf("unreg.rcptTo: expected '%s', found '%s'", topicName, unreg.rcptTo) + } + uaTimer.Stop() + notifTimer.Stop() + // Presence notifications. + if len(helper.hubMessages) != 1 { + t.Fatalf("Hub messages recipients: expected 1, received %d", len(helper.hubMessages)) + } + // Make sure uid's sessions are notified. + if userPres, ok := helper.hubMessages[uid.UserId()]; ok { + if len(userPres) != 1 { + t.Fatalf("User presence messages: expected 1, got %d", len(userPres)) + } + pres := userPres[0].Pres + if pres == nil { + t.Fatal("Presence message expected in hub output, but not found.") + } + if pres.Topic != "me" { + t.Errorf("Presence message topic: expected 'me', found '%s'", pres.Topic) + } + if pres.What != "off" { + t.Errorf("Presence message what: expected 'off', found '%s'", pres.What) + } + if pres.Src != topicName { + t.Errorf("Presence message src: expected '%s', found '%s'", topicName, pres.Src) + } + } else { + t.Errorf("Hub expected to pres recipient %s", uid.UserId()) + } +} + +func TestHandleTopicTermination(t *testing.T) { + topicName := "usrMe" + numUsers := 1 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatMe, topicName /*attach=*/, true) + defer helper.tearDown() + + done := make(chan bool, 1) + exit := &shutDown{ + reason: StopDeleted, + done: done, + } + helper.topic.handleTopicTermination(exit) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(done) != 1 { + t.Fatal("done callback isn't invoked.") + } + <-done + for i, s := range helper.sessions { + if len(s.detach) != 1 { + t.Fatalf("Session %d: detach channel is empty.", i) + } + val := <-s.detach + if val != topicName { + t.Errorf("Session %d is expected to detach from topic '%s', found '%s'.", i, topicName, val) + } + } + // Presence notifications. + if len(helper.hubMessages) != 0 { + t.Fatalf("Hub messages recipients: expected 0, received %d", len(helper.hubMessages)) + } +} + +func TestHandleBroadcastDataWithAttachments(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Original: from, + Pub: &MsgClientPub{ + Topic: "p2p", + Content: "Check out this image!", + Head: map[string]any{ + "attachments": []map[string]any{ + {"mime": "image/jpeg", "name": "photo.jpg", "size": 1024000}, + }, + }, + NoEcho: true, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Verify message with attachments was delivered + if len(helper.results[1].messages) != 1 { + t.Fatalf("Uid2: expected 1 message, got %d", len(helper.results[1].messages)) + } + r := helper.results[1].messages[0].(*ServerComMessage) + if r.Data == nil { + t.Fatal("Response must have a data message") + } + if r.Data.Head == nil { + t.Fatal("Response must have attachments in head") + } + attachments := r.Data.Head["attachments"] + if attachments == nil { + t.Fatal("Expected attachments in message head") + } +} + +func TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) { + topicName := "grpTest" + chanName := "chnTest" + numUsers := 5 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + helper.topic.isChan = true + defer helper.tearDown() + helper.topic.lastID = 15 + + readId := 12 + from := helper.uids[0] + + // Set up multiple channel readers + for i := 1; i < numUsers; i++ { + uid := helper.uids[i] + pud := helper.topic.perUser[uid] + pud.modeGiven = types.ModeCChnReader + pud.isChan = true + helper.topic.perUser[uid] = pud + } + + helper.ss.EXPECT().Update(types.TenantID(1), chanName, from, map[string]any{"ReadSeqId": readId}).Return(nil) + + msg := &ClientComMessage{ + AsUser: from.UserId(), + Original: chanName, + Note: &MsgClientNote{ + Topic: chanName, + What: "read", + SeqId: readId, + }, + sess: helper.sessions[0], + } + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Channel topics don't forward note messages to other users + for i, r := range helper.results { + if numMessages := len(r.messages); numMessages != 0 { + t.Errorf("User %d is not expected to receive any messages, %d received", i, numMessages) + } + } + + // Only sender gets presence notification + if len(helper.hubMessages) != 1 { + t.Fatalf("Hub expected exactly 1 recipient, got %d", len(helper.hubMessages)) + } + if _, ok := helper.hubMessages[from.UserId()]; !ok { + t.Fatal("Expected presence notification for sender") + } +} + +func TestRegisterSessionWithComplexModeString(t *testing.T) { + topicName := "grpTest" + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, false) + defer helper.tearDown() + + uid := helper.uids[1] + s := helper.sessions[1] + r := helper.results[1] + + // User with existing subscription wants to change mode + pud := helper.topic.perUser[uid] + pud.modeWant = types.ModeCPublic + pud.modeGiven = types.ModeCPublic + helper.topic.perUser[uid] = pud + + join := &ClientComMessage{ + Original: topicName, + Sub: &MsgClientSub{ + Id: "id456", + Topic: topicName, + Set: &MsgSetQuery{ + Sub: &MsgSetSub{ + Mode: "JRWPAS", // Complex mode string with multiple permissions + }, + }, + }, + AsUser: uid.UserId(), + AuthLvl: int(auth.LevelAuth), + sess: s, + } + + helper.ss.EXPECT().Update(types.TenantID(1), topicName, uid, gomock.Any()).Return(nil) + + helper.topic.registerSession(join) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + if len(helper.topic.sessions) != 1 { + t.Fatalf("Attached sessions: expected 1, found %d", len(helper.topic.sessions)) + } + if len(s.subs) != 1 { + t.Fatalf("Session subscriptions: expected 1, found %d", len(s.subs)) + } + online := helper.topic.perUser[uid].online + if online != 1 { + t.Fatalf("Number of online sessions: expected 1, found %d", online) + } + registerSessionVerifyOutputs(t, r, []int{http.StatusOK}) +} + +func TestHandleBroadcastDataGroupWithMutedUser(t *testing.T) { + topicName := "grp-test" + numUsers := 4 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, topicName, true) + defer helper.tearDown() + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + + // User 2 has muted the topic (no Pres permission) + pu2 := helper.topic.perUser[helper.uids[2]] + pu2.modeWant = types.ModeJoin | types.ModeRead | types.ModeWrite + pu2.modeGiven = pu2.modeWant + helper.topic.perUser[helper.uids[2]] = pu2 + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + AsUser: from, + Original: topicName, + Pub: &MsgClientPub{ + Topic: topicName, + Content: "test message", + NoEcho: true, + }, + sess: helper.sessions[0], + } + + helper.topic.handleClientMsg(msg) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // User 2 should still receive the message (has Read permission) + if len(helper.results[2].messages) != 1 { + t.Fatalf("Uid2: expected 1 message, got %d", len(helper.results[2].messages)) + } + + // Check presence notifications - muted user should not receive presence + if len(helper.hubMessages) != 3 { // Users 0, 1, 3 but not 2 + t.Fatalf("Hub expected 3 recipients, got %d", len(helper.hubMessages)) + } + + // Verify user 2 is not in presence notifications + if _, ok := helper.hubMessages[helper.uids[2].UserId()]; ok { + t.Fatal("Muted user should not receive presence notifications") + } +} + +func TestUnregisterSessionWithPendingCall(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test", true) + defer helper.tearDown() + + uid := helper.uids[0] + s := helper.sessions[0] + r := helper.results[0] + + // Set up a pending call matching the actual videoCall structure + helper.topic.currentCall = &videoCall{ + seq: 123, + parties: make(map[string]callPartyData), + } + helper.topic.currentCall.parties[s.sid] = callPartyData{ + uid: uid, + isOriginator: true, + sess: s, + } + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, true) + + leave := &ClientComMessage{ + Leave: &MsgClientLeave{ + Id: "id456", + Topic: "p2p-test", + }, + AsUser: uid.UserId(), + sess: s, + init: true, + } + + helper.topic.unregisterSession(leave) + helper.finish() + + // Check for errors from testHubLoop + if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError { + t.Fatal(errorMsgs[0].Ctrl.Text) + } + + // Verify session was unregistered + if len(helper.topic.sessions) != 1 { + t.Errorf("Attached sessions: expected 1, found %d", len(helper.topic.sessions)) + } + if len(s.subs) != 0 { + t.Errorf("Session subscriptions: expected 0, found %d", len(s.subs)) + } + + // Verify call party was removed (if the implementation handles this) + if helper.topic.currentCall != nil && helper.topic.currentCall.parties != nil { + if _, exists := helper.topic.currentCall.parties[s.sid]; exists { + t.Error("Call party should have been removed when session unregistered") + } + } + + if len(r.messages) != 3 { + t.Fatalf("`responses` expected to contain 3 elements, found %d", len(r.messages)) + } + + // Expected one of each: {data}, {info}, {ctrl}. + var found = 0 + for _, msg := range r.messages { + m := msg.(*ServerComMessage) + if m.Data != nil { + found++ + if m.Data.Head == nil || m.Data.Head["webrtc"] != "disconnected" || m.Data.Head["replace"] != ":123" { + t.Fatalf("Unexpected Data.Head: %+v", m.Data.Head) + } + } else if m.Info != nil { + found++ + if m.Info.SeqId != 123 { + t.Fatalf("Unexpected Info.SeqId: %d", m.Info.SeqId) + } + if m.Info.What != "call" { + t.Fatalf("Unexpected Info.What: %s", m.Info.What) + } + if m.Info.Event != "hang-up" { + t.Fatalf("Unexpected Info.Event: %s", m.Info.Event) + } + } else if m.Ctrl != nil { + found++ + if m.Ctrl.Code != http.StatusOK { + t.Fatalf("Unexpected Ctrl.Code: %d", m.Ctrl.Code) + } + } else { + t.Error("Expected only {data}, {info}, {ctrl} messages.") + } + } + + if found != 3 { + t.Fatal("Expected only {data}, {info}, {ctrl} messages, but some are missing") + } +} + +func TestReplyDelMsgHardDelete(t *testing.T) { + // Test hard delete scenario - hard deletes affect all users equally + // and don't update individual unread counters the same way as soft deletes + + topicName := "p2pTest" + helper := TopicTestHelper{} + helper.setUp(t, 2, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + + user1 := helper.uids[0] // User with delete permission + user2 := helper.uids[1] // Other user + + // Set up initial state: user2 has read up to message 5, topic has messages up to 10 + helper.topic.lastID = 10 + + pud1 := helper.topic.perUser[user1] + pud1.readID = 10 + pud1.modeGiven = types.ModeCFull // Full permissions including delete + pud1.modeWant = types.ModeCFull + helper.topic.perUser[user1] = pud1 + + pud2 := helper.topic.perUser[user2] + pud2.readID = 5 + pud2.modeGiven = types.ModeCFull + pud2.modeWant = types.ModeCFull + helper.topic.perUser[user2] = pud2 + + // Simulate user1 doing a hard delete of messages 7 and 8 + msg := &ClientComMessage{ + Del: &MsgClientDel{ + Id: "del123", + What: "msg", + DelSeq: []MsgRange{ + {LowId: 7, HiId: 9}, // Deletes messages 7 and 8 [7, 9) + }, + Hard: true, // Hard delete + }, + AsUser: user1.UserId(), + sess: helper.sessions[0], + init: true, + } + + // Mock the message deletion for hard delete (forUser = types.ZeroUid) + helper.mm.EXPECT().DeleteList(types.TenantID(1), topicName, 1, types.ZeroUid, gomock.Any(), []types.Range{{Low: 7, Hi: 9}}).Return(nil) + + // Call the function under test + err := helper.topic.replyDelMsg(helper.sessions[0], user1, false, msg) + + // Verify + if err != nil { + t.Fatalf("replyDelMsg failed: %v", err) + } + + // Verify session got success response + helper.finish() + registerSessionVerifyOutputs(t, helper.results[0], []int{http.StatusOK}) + + // For hard deletes, all users' delID should be updated + if helper.topic.perUser[user1].delID != 1 { + t.Errorf("Expected user1.delID to be 1, got %d", helper.topic.perUser[user1].delID) + } + if helper.topic.perUser[user2].delID != 1 { + t.Errorf("Expected user2.delID to be 1, got %d", helper.topic.perUser[user2].delID) + } +} + +func TestReplyDelMsgUpdatesUnreadCounters(t *testing.T) { + // This test simulates the scenario from issue #898: + // 1. User1 sends messages to User2 + // 2. User1 deletes some messages (soft delete) + // 3. Verify that the unread calculation logic works correctly + + topicName := "p2pTest" + helper := TopicTestHelper{} + helper.setUp(t, 2, types.TopicCatP2P, topicName, true) + defer helper.tearDown() + + user1 := helper.uids[0] // Sender/deleter + user2 := helper.uids[1] // Recipient + + // Set up initial state: user2 has read up to message 5, topic has messages up to 10 + // So user2 has 5 unread messages (6, 7, 8, 9, 10) + helper.topic.lastID = 10 + + pud1 := helper.topic.perUser[user1] + pud1.readID = 10 // user1 has read all + helper.topic.perUser[user1] = pud1 + + pud2 := helper.topic.perUser[user2] + pud2.readID = 5 // user2 has 5 unread messages + helper.topic.perUser[user2] = pud2 + + // Simulate user1 deleting messages 7 and 8 (2 of user2's unread messages) + msg := &ClientComMessage{ + Del: &MsgClientDel{ + Id: "del123", + What: "msg", + DelSeq: []MsgRange{ + {LowId: 7, HiId: 9}, // Deletes messages 7 and 8 [7, 9) + }, + Hard: false, // Soft delete + }, + AsUser: user1.UserId(), + sess: helper.sessions[0], + init: true, + } + + // Mock the message deletion + helper.mm.EXPECT().DeleteList(types.TenantID(1), topicName, 1, user1, time.Duration(0), []types.Range{{Low: 7, Hi: 9}}).Return(nil) + + // Call the function under test + err := helper.topic.replyDelMsg(helper.sessions[0], user1, false, msg) + + // Verify + if err != nil { + t.Fatalf("replyDelMsg failed: %v", err) + } + + // Verify session got success response + helper.finish() + registerSessionVerifyOutputs(t, helper.results[0], []int{http.StatusOK}) + + // The key verification is that calculateUnreadInRanges should have been called + // with the correct parameters. We can test this indirectly by testing the function: + ranges := []types.Range{{Low: 7, Hi: 9}} + unreadDeleted := calculateUnreadInRanges(5, 10, ranges) // user2's readID=5, lastID=10 + if unreadDeleted != 2 { + t.Errorf("Expected 2 unread messages to be deleted for user2, got %d", unreadDeleted) + } +} + +func TestCalculateUnreadInRanges(t *testing.T) { + tests := []struct { + name string + readID int + lastID int + ranges []types.Range + expected int + }{ + { + name: "no unread messages", + readID: 10, + lastID: 10, + ranges: []types.Range{{Low: 5, Hi: 15}}, + expected: 0, + }, + { + name: "no deleted messages in unread range", + readID: 5, + lastID: 10, + ranges: []types.Range{{Low: 1, Hi: 5}}, + expected: 0, + }, + { + name: "all unread messages deleted", + readID: 5, + lastID: 10, + ranges: []types.Range{{Low: 6, Hi: 11}}, + expected: 5, + }, + { + name: "partial unread messages deleted", + readID: 5, + lastID: 10, + ranges: []types.Range{{Low: 7, Hi: 9}}, + expected: 2, + }, + { + name: "single message deleted", + readID: 5, + lastID: 10, + ranges: []types.Range{{Low: 7, Hi: 0}}, // Hi: 0 means single message + expected: 1, + }, + { + name: "multiple ranges", + readID: 5, + lastID: 15, + ranges: []types.Range{{Low: 7, Hi: 9}, {Low: 12, Hi: 14}}, + expected: 4, // 2 messages in range [7,9) + 2 messages in range [12,14) + }, + { + name: "overlapping with unread boundaries", + readID: 5, + lastID: 10, + ranges: []types.Range{{Low: 4, Hi: 8}, {Low: 9, Hi: 12}}, + expected: 4, // [6,8) + [9,11) = 2 + 2 = 4 unread messages deleted + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateUnreadInRanges(tt.readID, tt.lastID, tt.ranges) + if result != tt.expected { + t.Errorf("calculateUnreadInRanges(%d, %d, %v) = %d; want %d", + tt.readID, tt.lastID, tt.ranges, result, tt.expected) + } + }) + } +} + +func TestMain(m *testing.M) { + logs.Init(os.Stderr, "stdFlags") + // Set max subscriber count to effective infinity. + globals.maxSubscriberCount = 1_000_000_000 + os.Exit(m.Run()) +} diff --git a/server/user.go b/server/user.go index e69de29..25aabe7 100644 --- a/server/user.go +++ b/server/user.go @@ -0,0 +1,1231 @@ +package main + +import ( + "container/heap" + "math/rand" + "time" + + "slices" + + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/push" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +const ( + // Unread counter update return codes. + // Counter not initialized, IO pending. + unreadUpdateIOPending = -1 + // Counter initialization error. + unreadUpdateError = -2 +) + +// Process request for a new account. +func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { + // The session cannot authenticate with the new account because it's already authenticated. + if msg.Acc.Login && (!s.uid.IsZero() || rec != nil) { + s.queueOut(ErrAlreadyAuthenticated(msg.Id, "", msg.Timestamp)) + logs.Warn.Println("create user: login requested while authenticated, sid=", s.sid) + return + } + + // Find authenticator for the requested scheme. + authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme) + if authhdl == nil { + // New accounts must have an authentication scheme + s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) + logs.Warn.Println("create user: unknown auth handler, sid=", s.sid) + return + } + + // Check if login is unique and compliance with the policy (not too long or too short). + authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr} + if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok { + logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, + map[string]any{"what": "auth"})) + return + } + + var user types.User + user.TenantID = s.tenantID + var private any + + // If account state is being assigned, make sure the sender is a root user. + if msg.Acc.State != "" { + if auth.Level(msg.AuthLvl) != auth.LevelRoot { + logs.Warn.Println("create user: attempt to set account state by non-root, sid=", s.sid) + msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp) + msg.Ctrl.Params = map[string]any{"what": "state"} + s.queueOut(msg) + return + } + + state, err := types.NewObjState(msg.Acc.State) + if err != nil || state == types.StateUndefined || state == types.StateDeleted { + logs.Warn.Println("create user: invalid account state", err, "sid=", s.sid) + s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) + return + } + user.State = state + } + + // Ensure tags are unique and not restricted. + if tags := normalizeTags(msg.Acc.Tags, globals.maxTagCount); tags != nil { + if !restrictedTagsEqual(tags, nil, globals.immutableTagNS) { + logs.Warn.Println("create user: attempt to directly assign restricted tags, sid=", s.sid) + msg := ErrPermissionDenied(msg.Id, "", msg.Timestamp) + msg.Ctrl.Params = map[string]any{"what": "tags"} + s.queueOut(msg) + return + } + user.Tags = tags + } + + // Pre-check credentials for validity. We don't know user's access level + // consequently cannot check presence of required credentials. Must do that later. + creds := normalizeCredentials(msg.Acc.Cred, true) + for i := range creds { + cr := &creds[i] + vld := store.Store.GetValidator(cr.Method) + if _, err := vld.PreCheck(cr.Value, cr.Params); err != nil { + logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, + map[string]any{"what": cr.Method})) + return + } + } + + // Assign default access values in case the acc creator has not provided them + user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) | + getDefaultAccess(types.TopicCatGrp, true, false) + user.Access.Anon = getDefaultAccess(types.TopicCatP2P, false, false) | + getDefaultAccess(types.TopicCatGrp, false, false) + + // Assign actual access values, public and private. + if msg.Acc.Desc != nil { + if msg.Acc.Desc.DefaultAcs != nil { + if msg.Acc.Desc.DefaultAcs.Auth != "" { + user.Access.Auth.UnmarshalText([]byte(msg.Acc.Desc.DefaultAcs.Auth)) + user.Access.Auth &= globals.typesModeCP2P + if user.Access.Auth != types.ModeNone { + user.Access.Auth |= types.ModeApprove + } + } + if msg.Acc.Desc.DefaultAcs.Anon != "" { + user.Access.Anon.UnmarshalText([]byte(msg.Acc.Desc.DefaultAcs.Anon)) + user.Access.Anon &= globals.typesModeCP2P + if user.Access.Anon != types.ModeNone { + user.Access.Anon |= types.ModeApprove + } + } + } + if !isNullValue(msg.Acc.Desc.Public) { + user.Public = msg.Acc.Desc.Public + } + if !isNullValue(msg.Acc.Desc.Private) { + private = msg.Acc.Desc.Private + } + } + + // Create user record in the database. + if _, err := store.Users.Create(s.tenantID, &user, private); err != nil { + logs.Warn.Println("create user: failed to create user", err, "sid=", s.sid) + s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp)) + return + } + + // Add authentication record. The authhdl.AddRecord may change tags. + rec, err := authhdl.AddRecord(authCtx, + &auth.Rec{TenantID: s.tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret) + if err != nil { + logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) + + // Attempt to delete incomplete user record + if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil { + logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid) + } + return + } + // When creating an account, the user must provide all required credentials. + // If any are missing, reject the request. + if len(creds) < len(globals.authValidators[rec.AuthLevel]) { + logs.Warn.Println("create user: missing credentials; have:", creds, "want:", + globals.authValidators[rec.AuthLevel], s.sid) + _, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], credentialMethods(creds)) + s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, + map[string]any{"creds": missing})) + + // Attempt to delete incomplete user record + if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil { + logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid) + } + return + } + + // Save credentials, update tags if necessary. + tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{ + TenantID: s.tenantID, + Uid: user.Uid(), + AuthLevel: auth.LevelAuth, + Lifetime: auth.Duration(time.Hour * 24), + }) + validated, _, err := addCreds(s.tenantID, user.Uid(), creds, rec.Tags, s.lang, tmpToken) + if err != nil { + logs.Warn.Println("create user: failed to save or validate credential", err, "sid=", s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) + + // Delete incomplete user record. + if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil { + logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid) + } + return + } + + if msg.Extra != nil && len(msg.Extra.Attachments) > 0 { + if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil { + logs.Warn.Println("create user: failed to link avatar attachment", err, "sid=", s.sid) + // This is not a critical error, continue execution. + } + } + + var reply *ServerComMessage + if msg.Acc.Login { + // Process user's login request. + _, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated) + reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing) + } else { + // Not using the new account for logging in. + reply = NoErrCreated(msg.Id, "", msg.Timestamp) + reply.Ctrl.Params = map[string]any{ + "user": user.Uid().UserId(), + "authlvl": rec.AuthLevel.String(), + } + } + + params := reply.Ctrl.Params.(map[string]any) + params["desc"] = &MsgTopicDesc{ + CreatedAt: &user.CreatedAt, + UpdatedAt: &user.UpdatedAt, + DefaultAcs: &MsgDefaultAcsMode{ + Auth: user.Access.Auth.String(), + Anon: user.Access.Anon.String(), + }, + Public: user.Public, + Private: private, + } + + s.queueOut(reply) + + pluginAccount(&user, plgActCreate) +} + +// Process update to an account: +// * Authentication update, i.e. login/password change +// * Credentials update +func replyUpdateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) { + if s.uid.IsZero() && rec == nil { + // Session is not authenticated and no temporary auth is provided. + logs.Warn.Println("replyUpdateUser: not a new account and not authenticated", s.sid) + s.queueOut(ErrPermissionDenied(msg.Id, "", msg.Timestamp)) + return + } else if msg.AsUser != "" && rec != nil { + // Two UIDs: one from msg.from, one from temporary auth. Ambigous, reject. + logs.Warn.Println("replyUpdateUser: got both authenticated session and token", s.sid) + s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) + return + } + + userId := msg.AsUser + authLvl := auth.Level(msg.AuthLvl) + if rec != nil { + userId = rec.Uid.UserId() + authLvl = rec.AuthLevel + } + + if msg.Acc.User != "" && msg.Acc.User != userId { + if s.authLvl != auth.LevelRoot { + logs.Warn.Println("replyUpdateUser: attempt to change another's account by non-root", s.sid) + s.queueOut(ErrPermissionDenied(msg.Id, "", msg.Timestamp)) + return + } + // Root is editing someone else's account. + userId = msg.Acc.User + authLvl = auth.ParseAuthLevel(msg.Acc.AuthLevel) + } + + uid := types.ParseUserId(userId) + if uid.IsZero() { + // msg.Acc.User contains invalid data. + s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) + logs.Warn.Println("replyUpdateUser: user id is invalid or missing", s.sid) + return + } + + // Only root can suspend accounts, including own account. + if msg.Acc.State != "" && s.authLvl != auth.LevelRoot { + s.queueOut(ErrPermissionDenied(msg.Id, "", msg.Timestamp)) + logs.Warn.Println("replyUpdateUser: attempt to change account state by non-root", s.sid) + return + } + + user, err := store.Users.Get(s.tenantID, uid) + if user == nil && err == nil { + err = types.ErrNotFound + } + if err != nil { + logs.Warn.Println("replyUpdateUser: failed to fetch user from DB", err, s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) + return + } + if user.TenantID != s.tenantID { + logs.Warn.Println("replyUpdateUser: target user is outside session tenant", s.sid) + s.queueOut(decodeStoreError(types.ErrNotFound, msg.Id, msg.Timestamp, nil)) + return + } + + var params map[string]any + if msg.Acc.Scheme != "" { + err = updateUserAuth(s.tenantID, msg, user, rec, s.remoteAddr) + } else if len(msg.Acc.Cred) > 0 { + if authLvl == auth.LevelNone { + // msg.Acc.AuthLevel contains invalid data. + s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) + logs.Warn.Println("replyUpdateUser: auth level is missing", s.sid) + return + } + // Handle request to update credentials. + tmpToken, _, _ := store.Store.GetLogicalAuthHandler("token").GenSecret(&auth.Rec{ + TenantID: s.tenantID, + Uid: uid, + AuthLevel: auth.LevelNone, + Lifetime: auth.Duration(time.Hour * 24), + Features: auth.FeatureNoLogin, + }) + _, _, err := addCreds(s.tenantID, uid, msg.Acc.Cred, nil, s.lang, tmpToken) + if err == nil { + if allCreds, err := store.Users.GetAllCreds(s.tenantID, uid, "", true); err != nil { + var validated []string + for i := range allCreds { + validated = append(validated, allCreds[i].Method) + } + _, missing, _ := stringSliceDelta(globals.authValidators[authLvl], validated) + if len(missing) > 0 { + params = map[string]any{"cred": missing} + } + } + } + } else if msg.Acc.State != "" { + var changed bool + changed, err = changeUserState(s, uid, user, msg) + if !changed && err == nil { + s.queueOut(InfoNotModified(msg.Id, "", msg.Timestamp)) + return + } + } else { + err = types.ErrMalformed + } + + if err != nil { + logs.Warn.Println("replyUpdateUser: failed to update user", err, s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) + return + } + + s.queueOut(NoErrParams(msg.Id, "", msg.Timestamp, params)) + + // Call plugin with the account update + pluginAccount(user, plgActUpd) +} + +// Authentication update +func updateUserAuth(tenantID types.TenantID, msg *ClientComMessage, user *types.User, _ *auth.Rec, + remoteAddr string) error { + authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme) + if authhdl != nil { + // Request to update auth of an existing account. Only basic & rest auth are currently supported + + // TODO(gene): support adding new auth schemes + + rec, err := authhdl.UpdateRecord(auth.AuthContext{TenantID: tenantID, RemoteAddr: remoteAddr}, + &auth.Rec{TenantID: tenantID, Uid: user.Uid(), Tags: user.Tags}, msg.Acc.Secret) + if err != nil { + return err + } + + // Tags may have been changed by authhdl.UpdateRecord, reset them. + // Can't do much with the error here, logging it but not returning. + if _, err = store.Users.UpdateTags(tenantID, user.Uid(), nil, nil, rec.Tags); err != nil { + logs.Warn.Println("updateUserAuth tags update failed:", err) + } + return nil + } + + // Invalid or unknown auth scheme + return types.ErrMalformed +} + +// addCreds adds new credentials and re-send validation request for existing ones. +// It also adds credential-defined tags if necessary. +// Returns methods validated in this call only. Returns either a full set of tags +// or nil for tags when tags are unchanged. +func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, extraTags []string, + lang string, tmpToken []byte) ([]string, []string, error) { + var validated []string + for i := range creds { + cr := &creds[i] + vld := store.Store.GetValidator(cr.Method) + if vld == nil || !vld.IsInitialized() { + // Ignore unknown or un-initialized validator. + continue + } + + isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken) + if err != nil { + return nil, nil, err + } + + if isNew && cr.Response != "" { + // If response is provided and vld.Request did not return an error, the new request was + // successfully validated. + validated = append(validated, cr.Method) + + // Generate tags for these confirmed credentials. + if globals.validators[cr.Method].addToTags { + extraTags = append(extraTags, cr.Method+":"+cr.Value) + } + } + } + + // Save tags potentially changed by the validator. + if len(extraTags) > 0 { + if utags, err := store.Users.UpdateTags(tenantID, uid, extraTags, nil, nil); err == nil { + extraTags = utags + } else { + logs.Warn.Println("add cred tags update failed:", err) + } + } else { + extraTags = nil + } + return validated, extraTags, nil +} + +// validatedCreds returns the list of validated credentials including those validated in this call. +// Returns all validated methods including those validated earlier and now. +// Returns either a full set of tags or nil for tags if tags are unchanged. +func validatedCreds(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, creds []MsgCredClient, + errorOnFail bool) ([]string, []string, error) { + // Check if credential validation is required. + if len(globals.authValidators[authLvl]) == 0 { + return nil, nil, nil + } + + // Get all validated methods + allCreds, err := store.Users.GetAllCreds(tenantID, uid, "", true) + if err != nil { + return nil, nil, err + } + + methods := make(map[string]struct{}) + for i := range allCreds { + methods[allCreds[i].Method] = struct{}{} + } + + // Add credentials which are validated in this call. + // Unknown validators are removed. + creds = normalizeCredentials(creds, false) + var tagsToAdd []string + for i := range creds { + cr := &creds[i] + if cr.Response == "" { + // Ignore empty response. + continue + } + + vld := store.Store.GetValidator(cr.Method) // No need to check for nil, unknown methods are removed earlier. + value, err := vld.Check(tenantID, uid, cr.Response) + + if err != nil { + // Check failed. + if storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrCredentials { + if errorOnFail { + // Report invalid response. + return nil, nil, types.ErrInvalidResponse + } + // Skip invalid response. Keep credential unvalidated. + continue + } + // Actual error. Report back. + return nil, nil, err + } + + // Check did not return an error: the request was successfully validated. + methods[cr.Method] = struct{}{} + + // Add validated credential to user's tags. + if globals.validators[cr.Method].addToTags { + tagsToAdd = append(tagsToAdd, cr.Method+":"+value) + } + } + + var tags []string + if len(tagsToAdd) > 0 { + // Save update to tags + if utags, err := store.Users.UpdateTags(tenantID, uid, tagsToAdd, nil, nil); err == nil { + tags = utags + } else { + logs.Warn.Println("validated creds tags update failed:", err) + tags = nil + } + } else { + tags = nil + } + + validated := make([]string, 0, len(methods)) + for method := range methods { + validated = append(validated, method) + } + + return validated, tags, nil +} + +// deleteCred deletes user's credential. +// Returns full set of remaining tags or nil if tags are unchanged. +func deleteCred(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, cred *MsgCredClient) ([]string, error) { + vld := store.Store.GetValidator(cred.Method) + if vld == nil || cred.Value == "" { + // Reject invalid request: unknown validation method or missing credential value. + return nil, types.ErrMalformed + } + + // Is this a required credential for this validation level? + isRequired := slices.Contains(globals.authValidators[authLvl], cred.Method) + + // If credential is required, make sure the method remains validated even after this credential is deleted. + if isRequired { + // There could be multiple validated credentials for the same method thus we are getting a map with count + // for each method. + + // Get all credentials of the given method. + allCreds, err := store.Users.GetAllCreds(tenantID, uid, cred.Method, false) + if err != nil { + return nil, err + } + + // Check if it's OK to delete: there is another validated value + // or this value is not validated in the first place. + var okTodelete bool + for _, cr := range allCreds { + if (cr.Done && cr.Value != cred.Value) || (!cr.Done && cr.Value == cred.Value) { + okTodelete = true + break + } + } + + if !okTodelete { + // Reject: this is the only validated credential and it must be provided. + return nil, types.ErrPolicy + } + } + + // The credential is either not required or more than one credential is validated for the given method. + err := vld.Remove(tenantID, uid, cred.Value) + if err != nil { + if err == types.ErrNotFound { + // Credential is not deleted because it's not found + err = nil + } + return nil, err + } + + // Remove generated tags for the deleted credential. + var tags []string + if globals.validators[cred.Method].addToTags { + // This error should not be returned to user. + if utags, err := store.Users.UpdateTags(tenantID, uid, nil, []string{cred.Method + ":" + cred.Value}, nil); err == nil { + tags = utags + } else { + logs.Warn.Println("delete cred: failed to update tags:", err) + tags = nil + } + } else { + tags = nil + } + + return tags, nil +} + +// Change user state: suspended/normal (ok). +// 1. Not needed -- Disable/enable logins (state checked after login). +// 2. If suspending, evict user's sessions. Skip this step if resuming. +// 3. Suspend/activate p2p with the user. +// 4. Suspend/activate grp topics where the user is the owner. +// 5. Update user's DB record. +func changeUserState(s *Session, uid types.Uid, user *types.User, msg *ClientComMessage) (bool, error) { + state, err := types.NewObjState(msg.Acc.State) + if err != nil || state == types.StateUndefined { + logs.Warn.Println("replyUpdateUser: invalid account state", s.sid) + return false, types.ErrMalformed + } + + // State unchanged. + if user.State == state { + return false, nil + } + + if state != types.StateOK { + // Terminate all sessions. + globals.sessionStore.EvictUser(uid, "") + } + + err = store.Users.UpdateState(s.tenantID, uid, state) + if err != nil { + return false, err + } + + // Update state of all loaded in memory user's p2p & grp-owner topics. + globals.hub.userStatus <- &userStatusReq{tenantID: s.tenantID, forUser: uid, state: state} + user.State = state + + return true, err +} + +// Request to delete a user: +// 1. Disable user's login +// 2. Terminate all user's sessions except the current session. +// 3. Stop all active topics +// 4. Notify other subscribers that topics are being deleted. +// 5. Delete user from the database. +// 6. Report success or failure. +// 7. Terminate user's last session. +func replyDelUser(s *Session, msg *ClientComMessage) { + var uid types.Uid + + if msg.Del.User == "" || msg.Del.User == s.uid.UserId() { + // Check if account deletion is disabled. + if globals.permanentAccounts && s.authLvl != auth.LevelRoot { + logs.Warn.Println("replyDelUser: account deletion disabled", s.sid) + s.queueOut(ErrPolicy(msg.Id, "", msg.Timestamp)) + return + } + + // Delete current user. + uid = s.uid + } else if s.authLvl == auth.LevelRoot { + // Delete another user. + uid = types.ParseUserId(msg.Del.User) + if uid.IsZero() { + logs.Warn.Println("replyDelUser: invalid user ID", msg.Del.User, s.sid) + s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp)) + return + } + state, err := userGetState(s.tenantID, uid) + if err != nil || state == types.StateDeleted { + logs.Warn.Println("replyDelUser: target user is outside session tenant", s.sid) + s.queueOut(ErrPermissionDenied(msg.Id, "", msg.Timestamp)) + return + } + } else { + logs.Warn.Println("replyDelUser: illegal attempt to delete another user", msg.Del.User, s.sid) + s.queueOut(ErrPermissionDenied(msg.Id, "", msg.Timestamp)) + return + } + + // Disable all authenticators + authnames := store.Store.GetAuthNames() + for _, name := range authnames { + hdl := store.Store.GetLogicalAuthHandler(name) + if !hdl.IsInitialized() { + continue + } + if err := hdl.DelRecords(s.tenantID, uid); err != nil { + // This could be completely benign, i.e. authenticator exists but not used. + logs.Warn.Println("replyDelUser: failed to delete auth record", uid.UserId(), name, err, s.sid) + if storeErr, ok := err.(types.StoreError); ok && storeErr == types.ErrUnsupported { + // Authenticator refused to delete record: user account cannot be deleted. + s.queueOut(ErrOperationNotAllowed(msg.Id, "", msg.Timestamp)) + return + } + } + } + + // Terminate all sessions. Skip the current session so the requester gets a response. + globals.sessionStore.EvictUser(uid, s.sid) + // Remove user from cache and announce to cluster that the user is deleted. + usersRemoveUser(msg.TenantID, uid) + + // Stop topics where the user is the owner and p2p topics. + done := make(chan bool) + globals.hub.unreg <- &topicUnreg{tenantID: s.tenantID, forUser: uid, del: msg.Del.Hard, done: done} + <-done + + // Notify users of interest that the user is gone. + if uoi, err := store.Users.GetSubs(s.tenantID, uid); err == nil { + presUsersOfInterestOffline(s.tenantID, uid, uoi, "gone") + } else { + logs.Warn.Println("replyDelUser: failed to send notifications to users", err, s.sid) + } + + // Notify subscribers of the group topics where the user was the owner that the topics were deleted. + if ownTopics, err := store.Users.GetOwnTopics(s.tenantID, uid); err == nil { + for _, topicName := range ownTopics { + if subs, err := store.Topics.GetSubs(s.tenantID, topicName, nil); err == nil { + presSubsOfflineOffline(s.tenantID, topicName, types.TopicCatGrp, subs, "gone", &presParams{}, s.sid) + } else { + logs.Warn.Println("replyDelUser: failed to notify topic subscribers", err, topicName, s.sid) + } + } + } else { + logs.Warn.Println("replyDelUser: failed to send notifications to owned topics", err, s.sid) + } + + // TODO: suspend all P2P topics with the user. + + // Delete user's records from the database. + if err := store.Users.Delete(s.tenantID, uid, msg.Del.Hard); err != nil { + logs.Warn.Println("replyDelUser: failed to delete user", err, s.sid) + s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) + return + } + + s.queueOut(NoErr(msg.Id, "", msg.Timestamp)) + + if s.uid == uid && s.multi == nil { + // Evict the current session if it belongs to the deleted user. + // No need to send it to multiplexing session: remote node will be notified separately. + _, data := s.serialize(NoErrEvicted("", "", msg.Timestamp)) + s.stopSession(data) + } +} + +// Read user's state from DB. +func userGetState(tenantID types.TenantID, uid types.Uid) (types.ObjState, error) { + user, err := store.Users.Get(tenantID, uid) + if err != nil { + return types.StateUndefined, err + } + if user == nil { + return types.StateUndefined, types.ErrUserNotFound + } + if user.TenantID != tenantID { + return types.StateUndefined, types.ErrUserNotFound + } + return user.State, nil +} + +// Subscribe or unsubscribe a single user's device to/from all FCM topics (channels). +func userChannelsSubUnsub(tenantID types.TenantID, uid types.Uid, deviceID string, sub bool) { + push.ChannelSub(&push.ChannelReq{ + TenantID: tenantID, + Uid: uid, + DeviceID: deviceID, + Unsub: !sub, + }) +} + +// UserCacheReq contains data which mutates one or more user cache entries. +type UserCacheReq struct { + TenantID types.TenantID + // Name of the node sending this request in case of cluster. Not set otherwise. + Node string + + // UserId is set when count of unread messages is updated for a single user or + // when the user is being deleted. + UserId types.Uid + // UserIdList is set when subscription count is updated for users of a topic. + UserIdList []types.Uid + // Unread count (UserId is set) + Unread int + + // In case of set UserId: treat Unread count as an increment as opposite to the final value. + // In case of set UserIdList: intement (Inc == true) or decrement subscription count by one. + Inc bool + // User is being deleted, remove user from cache. + Gone bool + + // Optional push notification + PushRcpt *push.Receipt +} + +type userCacheEntry struct { + unread int + topics int +} + +// Preserved update entry kept while we read the unread counter from the DB. +type bufferedUpdate struct { + val int + inc bool +} + +type ioResult struct { + tenantID types.TenantID + counts map[types.Uid]int + err error +} + +// Represents pending push notification receipt. +type pendingReceipt struct { + // Number of unread counters currently being read from the DB. + pendingIOs int + // The index is needed by update and is maintained by the heap.Interface methods. + index int + // Underlying receipt. + rcpt *push.Receipt +} + +// Pending pushes organized as a priority queue (priority = number of pending IOs). +// It allows to quickly discover receipts ready for sending (num pending IOs is 0). +type pendingReceiptsQueue []*pendingReceipt + +// Heap interface methods. +func (pq pendingReceiptsQueue) Len() int { return len(pq) } + +func (pq pendingReceiptsQueue) Less(i, j int) bool { + // We want Pop to give us the highest, not lowest, priority so we use greater than here. + return pq[i].pendingIOs < pq[j].pendingIOs +} + +func (pq pendingReceiptsQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] + pq[i].index = i + pq[j].index = j +} + +func (pq *pendingReceiptsQueue) Push(x any) { + n := len(*pq) + item := x.(*pendingReceipt) + item.index = n + *pq = append(*pq, item) +} + +func (pq *pendingReceiptsQueue) Pop() any { + old := *pq + n := len(old) + item := old[n-1] + old[n-1] = nil // avoid memory leak + item.index = -1 // for safety + *pq = old[0 : n-1] + return item +} + +func (pq *pendingReceiptsQueue) fix(index int) { + heap.Fix(pq, index) +} + +// Initialize users cache. +func usersInit() { + globals.usersUpdate = make(chan *UserCacheReq, 1024) + + go userUpdater() +} + +// Shutdown users cache. +func usersShutdown() { + if globals.usersUpdate != nil { + globals.usersUpdate <- nil + } +} + +func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int, inc bool) { + if globals.usersUpdate == nil || (val == 0 && inc) { + return + } + + upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: val, Inc: inc} + if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) { + // Send request to remote node which owns the user. + globals.cluster.routeUserReq(upd) + } else { + select { + case globals.usersUpdate <- upd: + default: + } + } +} + +// Start tracking a single user. Used for cache management. +// 'add' increments/decrements user's count of subscribed topics. +func usersRegisterUser(tenantID types.TenantID, uid types.Uid, add bool) { + if globals.usersUpdate == nil { + return + } + + upd := &UserCacheReq{TenantID: tenantID, UserIdList: make([]types.Uid, 1), Inc: add} + upd.UserIdList[0] = uid + + if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) { + // Send request to remote node which owns the user. + globals.cluster.routeUserReq(upd) + } else { + select { + case globals.usersUpdate <- upd: + default: + } + } +} + +// Stop tracking user and remove him from cache. +func usersRemoveUser(tenantID types.TenantID, uid types.Uid) { + if globals.usersUpdate == nil { + return + } + + upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Gone: true} + if !globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) { + select { + case globals.usersUpdate <- upd: + default: + } + } + + if globals.cluster != nil { + // Announce to cluster even if the user is local. + globals.cluster.routeUserReq(upd) + } +} + +// Account users as members of an active topic. Used for cache management. +// In case of a cluster this method is called only when the topic is local: +// globals.cluster.isRemoteTopic(t.name) == false +func usersRegisterTopic(t *Topic, add bool) { + if globals.usersUpdate == nil { + return + } + + if t.cat == types.TopicCatFnd || t.cat == types.TopicCatMe { + // Ignoring me and fnd topics. + return + } + + local := &UserCacheReq{TenantID: t.tenantID, Inc: add} + + // In case of a cluster UIDs could be local and remote. Process local UIDs locally, + // send remote UIDs to other cluster nodes for processing. The UIDs may have to be + // sent to multiple nodes. + remote := &UserCacheReq{TenantID: t.tenantID, Inc: add} + for uid, pud := range t.perUser { + if pud.isChan { + // Skip channel subscribers. + continue + } + if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: t.tenantID, Topic: uid.UserId()}) { + remote.UserIdList = append(remote.UserIdList, uid) + } else { + local.UserIdList = append(local.UserIdList, uid) + } + } + + if len(remote.UserIdList) > 0 { + globals.cluster.routeUserReq(remote) + } + + if len(local.UserIdList) > 0 { + select { + case globals.usersUpdate <- local: + default: + logs.Err.Println("User cache: globals.usersUpdate queue full: ", len(globals.usersUpdate)) + } + } +} + +// usersRequestFromCluster handles requests which came from other cluser nodes. +func usersRequestFromCluster(req *UserCacheReq) { + if globals.usersUpdate == nil { + return + } + + select { + case globals.usersUpdate <- req: + default: + } +} + +var usersCache map[types.UserKey]userCacheEntry + +// The go routine for processing updates to users cache. +func userUpdater() { + // Caches unread counters and numbers of topics the user's subscribed to. + usersCache = make(map[types.UserKey]userCacheEntry) + + // Unread counter updates blocked by IO on per user basis. We flush them when the IO completes. + perUserBuffers := make(map[types.UserKey][]bufferedUpdate) + + // Push notification recipients blocked by IO (unread counters for some of the recipients + // are being read from the database) on the per user basis. + perUserPendingReceipts := make(map[types.UserKey][]*pendingReceipt) + + // All pending push receipts organized as a priority queue by the number of pending IOs. + receiptQueue := pendingReceiptsQueue{} + + // IO callback queue. + ioDone := make(chan *ioResult, 1024) + + unreadUpdater := func(tenantID types.TenantID, uids []types.Uid, vals []int, inc bool) map[types.Uid]int { + var dbPending []types.Uid + counts := make(map[types.Uid]int, len(uids)) + for i, uid := range uids { + key := types.UserKey{TenantID: tenantID, User: uid} + counts[uid] = 0 + uce, ok := usersCache[key] + if !ok { + logs.Err.Println("ERROR: attempt to update unread count for user who has not been loaded", uid) + counts[uid] = unreadUpdateError + continue + } + + val := vals[i] + if uce.unread < 0 { + // Unread counter not initialized yet. Maybe start a DB read? + if updateBuf, ioInProgress := perUserBuffers[key]; ioInProgress { + // Buffer this update. + updateBuf = append(updateBuf, bufferedUpdate{val: val, inc: inc}) + perUserBuffers[key] = updateBuf + } else { + // Schedule reading the counter from DB. + updateBuf = []bufferedUpdate{} + perUserBuffers[key] = updateBuf + dbPending = append(dbPending, uid) + } + counts[uid] = unreadUpdateIOPending + continue + + } else if inc { + uce.unread += val + } else { + uce.unread = val + } + + usersCache[key] = uce + counts[uid] = uce.unread + } + + if len(dbPending) > 0 { + go func() { + dbUnread, err := store.Users.GetUnreadCount(tenantID, dbPending...) + if err != nil { + logs.Warn.Println("users: failed to load unread count: ", err) + } + ioDone <- &ioResult{tenantID: tenantID, counts: dbUnread, err: err} + }() + } + + return counts + } + + for { + select { + case io := <-ioDone: + // Unread counter read has completed. + for uid, count := range io.counts { + key := types.UserKey{TenantID: io.tenantID, User: uid} + updateBuf, ok := perUserBuffers[key] + // Stop buffering updates. New updates will be handled normally. + delete(perUserBuffers, key) + if io.err != nil { + continue + } + + // Update counter. + if ok { + for _, upd := range updateBuf { + if upd.inc { + count += upd.val + } else { + count = upd.val + } + } + } else { + logs.Warn.Println("ERROR: io didn't have an update buffer, uid", uid) + } + + if uce, ok := usersCache[key]; ok { + if uce.unread >= 0 { + logs.Warn.Println("users: unread count double initialization, uid", uid) + } + uce.unread = count + usersCache[key] = uce + } else { + logs.Warn.Println("users: missing users cache entry after IO completion, uid", uid) + } + + // Now that the unread counter is initialized, handle pending push notification receipts. + // Decrease pending IO counts in pending push receipts for this user. + if pendingReceipts, ok := perUserPendingReceipts[key]; ok { + for _, pp := range pendingReceipts { + pp.pendingIOs-- + receiptQueue.fix(pp.index) + } + delete(perUserPendingReceipts, key) + } + } + + if io.err != nil { + logs.Err.Println("users: failed to read unread count:", io.err) + continue + } + + // Send ready receipts. + for receiptQueue.Len() > 0 && receiptQueue[0].pendingIOs == 0 { + rcpt := heap.Pop(&receiptQueue).(*pendingReceipt).rcpt + for uid, rcptTo := range rcpt.To { + key := types.UserKey{TenantID: rcpt.TenantID, User: uid} + if uce, ok := usersCache[key]; ok && uce.unread >= 0 { + rcptTo.Unread = uce.unread + rcpt.To[uid] = rcptTo + } + } + push.Push(rcpt) + } + case upd := <-globals.usersUpdate: + if globals.shuttingDown { + // If shutdown is in progress we don't care to process anything. + // ignore all calls. + continue + } + + // Shutdown requested. + if upd == nil { + globals.usersUpdate = nil + // Dont' care to close the channel. + goto Exit + } + + // Request to send push notifications. + if upd.PushRcpt != nil { + // List of uids for which the unread count is being read from the DB. + pendingUsers := []types.Uid{} + + allUids := make([]types.Uid, 0, len(upd.PushRcpt.To)) + allDeltas := make([]int, 0, len(upd.PushRcpt.To)) + for uid, r := range upd.PushRcpt.To { + allUids = append(allUids, uid) + delta := 0 + if r.ShouldIncrementUnreadCountInCache { + delta = 1 + } + allDeltas = append(allDeltas, delta) + } + + allUnread := unreadUpdater(upd.TenantID, allUids, allDeltas, true) + for uid, unread := range allUnread { + rcptTo := upd.PushRcpt.To[uid] + // Handle update + if unread >= 0 { + rcptTo.Unread = unread + upd.PushRcpt.To[uid] = rcptTo + } else if unread == unreadUpdateIOPending { + pendingUsers = append(pendingUsers, uid) + } + } + + if len(pendingUsers) == 0 { + // All data present in memory. Just send the push. + push.Push(upd.PushRcpt) + } else { + // We are waiting for IO. Add this receipt to the queues. + pp := &pendingReceipt{ + pendingIOs: len(pendingUsers), + rcpt: upd.PushRcpt, + } + for _, uid := range pendingUsers { + key := types.UserKey{TenantID: upd.TenantID, User: uid} + var queue []*pendingReceipt + var ok bool + if queue, ok = perUserPendingReceipts[key]; !ok { + queue = []*pendingReceipt{} + } + perUserPendingReceipts[key] = append(queue, pp) + } + heap.Push(&receiptQueue, pp) + } + continue + } + + // Request to add/remove user from cache. + if len(upd.UserIdList) > 0 { + for _, uid := range upd.UserIdList { + key := types.UserKey{TenantID: upd.TenantID, User: uid} + uce, ok := usersCache[key] + if upd.Inc { + if !ok { + // This is a registration of a new user. + // We are not loading unread count here, so set it to -1. + uce.unread = -1 + } + uce.topics++ + usersCache[key] = uce + } else if ok { + if uce.topics > 1 { + uce.topics-- + usersCache[key] = uce + } else { + // Remove user from cache + delete(usersCache, key) + } + } else { + // BUG! + logs.Err.Println("ERROR: request to unregister user which has not been registered", uid) + } + } + continue + } + + if upd.Gone { + // User is being deleted. Don't care if there is a record. + delete(usersCache, types.UserKey{TenantID: upd.TenantID, User: upd.UserId}) + continue + } + + // Request to update unread count for one user. + unreadUpdater(upd.TenantID, []types.Uid{upd.UserId}, []int{upd.Unread}, upd.Inc) + } + } + +Exit: + logs.Info.Println("users: shutdown") +} + +// garbageCollectUsers runs every 'period' and deletes up to 'blockSize' +// stale unvalidated user accounts which have been last updated at least +// 'minAccountAgeHours' hours. +// Returns channel which can be used to stop the process. +func garbageCollectUsers(period time.Duration, blockSize, minAccountAgeHours int) chan<- bool { + // Unbuffered stop channel. Whomever stops the gc must wait for the process to finish. + stop := make(chan bool) + go func() { + // Add some randomness to the tick period to desynchronize runs on cluster nodes: + // 0.75 * period + rand(0, 0.5) * period. + period = period - (period >> 2) + time.Duration(rand.Intn(int(period>>1))) + gcTicker := time.Tick(period) + logs.Info.Printf("Stale account GC started with period %s, block size %d, min account age %d hours", + period.Round(time.Second), blockSize, minAccountAgeHours) + staleAge := time.Hour * time.Duration(minAccountAgeHours) + for { + select { + case <-gcTicker: + for _, tenantID := range store.KnownTenantIDs() { + if uids, err := store.Users.GetUnvalidated(tenantID, time.Now().Add(-staleAge), blockSize); err == nil { + if len(uids) > 0 { + logs.Info.Println("Stale account GC will delete uids:", tenantID, uids) + for _, uid := range uids { + if err = store.Users.Delete(tenantID, uid, true); err != nil { + logs.Warn.Printf("Stale account GC failed to delete %s: %+v", uid.UserId(), err) + } + } + } + } else { + logs.Warn.Println("Stale account GC error:", tenantID, err) + } + } + case <-stop: + return + } + } + }() + + return stop +} diff --git a/server/utils.go b/server/utils.go index e69de29..dfdabd4 100644 --- a/server/utils.go +++ b/server/utils.go @@ -0,0 +1,932 @@ +// Generic data manipulation utilities. + +package main + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "path/filepath" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" + + "maps" + + "golang.org/x/crypto/acme/autocert" +) + +// Tag with prefix: +// * prefix starts with an ASCII letter, contains ASCII letters, numbers, from 2 to 16 chars +// * tag body may contain Unicode letters and numbers, as well as the following symbols: +-.!?#@_ +// Tag body can be up to maxTagLength (96) chars long. +var prefixedTagRegexp = regexp.MustCompile(`^([a-z]\w{1,15}):([-_+.!?#@\pL\pN]{1,96})$`) + +// Generic tag: the same restrictions as tag body. +var tagRegexp = regexp.MustCompile(`^[-_+.!?#@\pL\pN]{1,96}$`) + +const nullValue = "\u2421" + +// Convert database ranges into wire protocol ranges. +func rangeDeserialize(in []types.Range) []MsgRange { + if len(in) == 0 { + return nil + } + + out := make([]MsgRange, 0, len(in)) + for _, r := range in { + out = append(out, MsgRange{LowId: r.Low, HiId: r.Hi}) + } + + return out +} + +// Convert wire protocol ranges into database ranges. +func rangeSerialize(in []MsgRange) []types.Range { + if len(in) == 0 { + return nil + } + + out := make([]types.Range, 0, len(in)) + for _, r := range in { + out = append(out, types.Range{Low: r.LowId, Hi: r.HiId}) + } + + return out +} + +// stringSliceDelta extracts the slices of added and removed strings from two slices: +// +// added := newSlice - (oldSlice & newSlice) -- present in new but missing in old +// removed := oldSlice - (oldSlice & newSlice) -- present in old but missing in new +// intersection := oldSlice & newSlice -- present in both old and new +func stringSliceDelta(rold, rnew []string) (added, removed, intersection []string) { + if len(rold) == 0 && len(rnew) == 0 { + return nil, nil, nil + } + if len(rold) == 0 { + return rnew, nil, nil + } + if len(rnew) == 0 { + return nil, rold, nil + } + + sort.Strings(rold) + sort.Strings(rnew) + + // Match old slice against the new slice and separate removed strings from added. + o, n := 0, 0 + lold, lnew := len(rold), len(rnew) + for o < lold || n < lnew { + if o == lold || (n < lnew && rold[o] > rnew[n]) { + // Present in new, missing in old: added + added = append(added, rnew[n]) + n++ + + } else if n == lnew || rold[o] < rnew[n] { + // Present in old, missing in new: removed + removed = append(removed, rold[o]) + o++ + + } else { + // present in both + intersection = append(intersection, rold[o]) + if o < lold { + o++ + } + if n < lnew { + n++ + } + } + } + return added, removed, intersection +} + +// Process credentials for correctness: remove duplicate and unknown methods. +// In case of duplicate methods only the first one satisfying valueRequired is kept. +// If valueRequired is true, keep only those where Value is non-empty. +func normalizeCredentials(creds []MsgCredClient, valueRequired bool) []MsgCredClient { + if len(creds) == 0 { + return nil + } + + index := make(map[string]*MsgCredClient) + for i := range creds { + c := &creds[i] + if _, ok := globals.validators[c.Method]; ok && (!valueRequired || c.Value != "") { + index[c.Method] = c + } + } + creds = make([]MsgCredClient, 0, len(index)) + for _, c := range index { + creds = append(creds, *index[c.Method]) + } + return creds +} + +// Get a string slice with methods of credentials. +func credentialMethods(creds []MsgCredClient) []string { + out := make([]string, len(creds)) + for i := range creds { + out[i] = creds[i].Method + } + return out +} + +// Takes MsgClientGet query parameters, returns database query parameters +func msgOpts2storeOpts(req *MsgGetOpts) *types.QueryOpt { + var opts *types.QueryOpt + if req != nil { + opts = &types.QueryOpt{ + User: types.ParseUserId(req.User), + Topic: req.Topic, + IfModifiedSince: req.IfModifiedSince, + Limit: req.Limit, + Since: req.SinceId, + Before: req.BeforeId, + IdRanges: rangeSerialize(req.IdRanges), + } + } + return opts +} + +// Check if the interface contains a string with a single Unicode Del control character. +func isNullValue(i any) bool { + if str, ok := i.(string); ok { + return str == nullValue + } + return false +} + +func decodeStoreError(err error, id string, ts time.Time, params map[string]any) *ServerComMessage { + return decodeStoreErrorExplicitTs(err, id, "", ts, ts, params) +} + +func decodeStoreErrorExplicitTs(err error, id, topic string, serverTs, incomingReqTs time.Time, + params map[string]any) *ServerComMessage { + + var errmsg *ServerComMessage + + if err == nil { + errmsg = NoErrExplicitTs(id, topic, serverTs, incomingReqTs) + } else if storeErr, ok := err.(types.StoreError); !ok { + errmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs) + } else { + switch storeErr { + case types.ErrInternal: + errmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs) + case types.ErrMalformed: + errmsg = ErrMalformedExplicitTs(id, topic, serverTs, incomingReqTs) + case types.ErrFailed: + errmsg = ErrAuthFailed(id, topic, serverTs, incomingReqTs) + case types.ErrPermissionDenied: + errmsg = ErrPermissionDeniedExplicitTs(id, topic, serverTs, incomingReqTs) + case types.ErrDuplicate: + errmsg = ErrDuplicateCredential(id, topic, serverTs, incomingReqTs) + case types.ErrUnsupported: + errmsg = ErrNotImplemented(id, topic, serverTs, incomingReqTs) + case types.ErrExpired: + errmsg = ErrAuthFailed(id, topic, serverTs, incomingReqTs) + case types.ErrPolicy: + errmsg = ErrPolicyExplicitTs(id, topic, serverTs, incomingReqTs) + case types.ErrCredentials: + errmsg = InfoValidateCredentialsExplicitTs(id, serverTs, incomingReqTs) + case types.ErrUserNotFound: + errmsg = ErrUserNotFound(id, topic, serverTs, incomingReqTs) + case types.ErrTopicNotFound: + errmsg = ErrTopicNotFound(id, topic, serverTs, incomingReqTs) + case types.ErrNotFound: + errmsg = ErrNotFoundExplicitTs(id, topic, serverTs, incomingReqTs) + case types.ErrInvalidResponse: + errmsg = ErrInvalidResponse(id, topic, serverTs, incomingReqTs) + case types.ErrRedirected: + errmsg = InfoUseOther(id, topic, params["topic"].(string), serverTs, incomingReqTs) + default: + errmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs) + } + } + + if params != nil { + errmsg.Ctrl.Params = params + } + + return errmsg +} + +// Helper function to select access mode for the given auth level +func selectAccessMode(authLvl auth.Level, anonMode, authMode, rootMode types.AccessMode) types.AccessMode { + switch authLvl { + case auth.LevelNone: + return types.ModeNone + case auth.LevelAnon: + return anonMode + case auth.LevelAuth: + return authMode + case auth.LevelRoot: + return rootMode + default: + return types.ModeNone + } +} + +// Get default modeWant for the given topic category +func getDefaultAccess(cat types.TopicCat, authUser, isChan bool) types.AccessMode { + if !authUser { + return types.ModeNone + } + + switch cat { + case types.TopicCatP2P: + return globals.typesModeCP2P + case types.TopicCatFnd: + return types.ModeNone + case types.TopicCatGrp: + if isChan { + return types.ModeCChnWriter + } + return types.ModeCPublic + case types.TopicCatMe: + return types.ModeCMeFnd + case types.TopicCatSlf: + return types.ModeCSelf + default: + panic("Unknown topic category") + } +} + +// Parse topic access parameters +func parseTopicAccess(acs *MsgDefaultAcsMode, defAuth, defAnon types.AccessMode) (authMode, anonMode types.AccessMode, + err error) { + + authMode, anonMode = defAuth, defAnon + + if acs.Auth != "" { + err = authMode.UnmarshalText([]byte(acs.Auth)) + } + if acs.Anon != "" { + err = anonMode.UnmarshalText([]byte(acs.Anon)) + } + + return +} + +// Parse one component of a semantic version string. +func parseVersionPart(vers string) int { + end := strings.IndexFunc(vers, func(r rune) bool { + return !unicode.IsDigit(r) + }) + + t := 0 + var err error + if end > 0 { + t, err = strconv.Atoi(vers[:end]) + } else if len(vers) > 0 { + t, err = strconv.Atoi(vers) + } + if err != nil || t > 0x1fff || t <= 0 { + return 0 + } + return t +} + +// Parses semantic version string in the following formats: +// +// 1.2, 1.2abc, 1.2.3, 1.2.3-abc, v0.12.34-rc5 +// +// Unparceable values are replaced with zeros. +func parseVersion(vers string) int { + var major, minor, patch int + // Maybe remove the optional "v" prefix. + vers = strings.TrimPrefix(vers, "v") + + // We can handle 3 parts only. + parts := strings.SplitN(vers, ".", 3) + count := len(parts) + if count > 0 { + major = parseVersionPart(parts[0]) + if count > 1 { + minor = parseVersionPart(parts[1]) + if count > 2 { + patch = parseVersionPart(parts[2]) + } + } + } + + return (major << 16) | (minor << 8) | patch +} + +// Version as a base-10 number. Used by monitoring. +func base10Version(hex int) int64 { + major := hex >> 16 & 0xFF + minor := hex >> 8 & 0xFF + trailer := hex & 0xFF + return int64(major*10000 + minor*100 + trailer) +} + +func versionToString(vers int) string { + str := strconv.Itoa(vers>>16) + "." + strconv.Itoa((vers>>8)&0xff) + if vers&0xff != 0 { + str += "-" + strconv.Itoa(vers&0xff) + } + return str +} + +// Tag handling + +// filterTags takes a slice of tags and a map of namespaces, return a slice of namespace tags +// contained in the input. +// params: Tags to filter, namespaces to use as the filter. +func filterTags(tags []string, namespaces map[string]bool) []string { + var out []string + if len(namespaces) == 0 { + return out + } + + for _, s := range tags { + parts := prefixedTagRegexp.FindStringSubmatch(s) + + if len(parts) < 2 { + continue + } + + // [1] is the prefix. [0] is the whole tag. + if namespaces[parts[1]] { + out = append(out, s) + } + } + + return out +} + +// rewriteTag attempts to match the original token against the email and telephone number. +// The tag is expected to be in lowercase. +// On success, it returns a slice with the original tag and the tag with the corresponding prefix. It returns an +// empty slice if the tag is invalid. +// TODO: consider inferring country code from user location. +func rewriteTag(orig, countryCode string) []string { + // Check if the tag already has a prefix e.g. basic:alice. + if prefixedTagRegexp.MatchString(orig) { + return []string{orig} + } + + // Check if token can be rewritten by any of the validators + param := map[string]any{"countryCode": countryCode} + for name, conf := range globals.validators { + if conf.addToTags { + val := store.Store.GetValidator(name) + if tag, _ := val.PreCheck(orig, param); tag != "" { + return []string{orig, tag} + } + } + } + + if tagRegexp.MatchString(orig) { + return []string{orig} + } + + // invalid generic tag + + return nil +} + +// rewriteTagSlice calls rewriteTag for each slice member and return a new slice with original and converted values. +func rewriteTagSlice(tags []string, countryCode string) []string { + var result []string + for _, tag := range tags { + rewritten := rewriteTag(tag, countryCode) + if len(rewritten) != 0 { + result = append(result, rewritten...) + } + } + return result +} + +// restrictedTagsEqual checks if two sets of tags contain the same set of restricted tags: +// true - same, false - different. +func restrictedTagsEqual(oldTags, newTags []string, namespaces map[string]bool) bool { + rold := filterTags(oldTags, namespaces) + rnew := filterTags(newTags, namespaces) + + if len(rold) != len(rnew) { + return false + } + + sort.Strings(rold) + sort.Strings(rnew) + + // Match old tags against the new tags. + for i := range rnew { + if rold[i] != rnew[i] { + return false + } + } + + return true +} + +// Trim whitespace, remove short/empty tags and duplicates, convert to lowercase, ensure +// the number of tags does not exceed the maximum. +func normalizeTags(src []string, maxTags int) types.StringSlice { + if src == nil { + return nil + } + + // Make sure the number of tags does not exceed the maximum. + // Technically it may result in fewer tags than the maximum due to empty tags and + // duplicates, but that's user's fault. + if len(src) > maxTags { + src = src[:maxTags] + } + + // Trim whitespace and force to lowercase. + for i := range src { + src[i] = strings.ToLower(strings.TrimSpace(src[i])) + } + + // Sort tags + sort.Strings(src) + + // Remove short, invalid tags and de-dupe keeping the order. It may result in fewer tags than could have + // been if length were enforced later, but that's client's fault. + var prev string + var dst []string + for _, curr := range src { + if isNullValue(curr) { + // Return non-nil empty array + return make([]string, 0, 1) + } + + // Unicode handling + ucurr := []rune(curr) + + // Enforce length in characters, not in bytes. + if len(ucurr) < minTagLength || len(ucurr) > maxTagLength || curr == prev { + continue + } + + // Make sure the tag starts with a letter or a number. + if unicode.IsLetter(ucurr[0]) || unicode.IsDigit(ucurr[0]) { + dst = append(dst, curr) + prev = curr + } + } + + return types.StringSlice(dst) +} + +func validateTag(tag string) (string, string) { + // Check if the tag already has a prefix e.g. basic:alice. + if parts := prefixedTagRegexp.FindStringSubmatch(tag); len(parts) == 3 { + // Valid prefixed tag. + return parts[1], parts[2] + } + + if tagRegexp.MatchString(tag) { + // Valid unprefixed tag (tag value only). + return "", tag + } + + return "", "" +} + +// hasDuplicateNamespaceTags checks for duplication of unique NS tags. +// Each namespace can have only one tag. This does not prevent tags from +// being duplicate across requests, just saves an extra DB call. +func hasDuplicateNamespaceTags(src []string, uniqueNS string) bool { + found := map[string]bool{} + for _, tag := range src { + parts := prefixedTagRegexp.FindStringSubmatch(tag) + if len(parts) != 3 { + // Invalid tag, ignored. + continue + } + + if uniqueNS == parts[1] && found[parts[1]] { + return true + } + found[parts[1]] = true + } + return false +} + +// Parser for search queries. The query may contain non-ASCII characters, +// i.e. length of string in bytes != length of string in runes. +// Returns +// * required tags: AND tags (at least one must be present in every result), +// * optional tags +// * error. +func parseSearchQuery(query string) ([]string, []string, error) { + const ( + NONE = iota + QUO // 1 + AND // 2 + OR // 3 + END // 4 + ORD // 5 + ) + type token struct { + op int + val string + } + type context struct { + // Pre-token operand. + preOp int + // Post-token operand. + postOp int + // Inside quoted string. + quo bool + // Start of the current token. + start int + // End of the current token. + end int + } + ctx := context{preOp: AND} + var out []token + var prev int + query = strings.TrimSpace(query) + // Split query into tokens. + // i - character index into the string. + // pos - rune index into the string. + // w - width of the current rune in characters. + for i, w, pos := 0, 0, 0; prev != END; i, pos = i+w, pos+1 { + // + var emit bool + + // Lexer: get next rune. + var r rune + // Ordinary character by default. + curr := ORD + r, w = utf8.DecodeRuneInString(query[i:]) + switch { + case w == 0: + // Width zero: end of the string. + curr = END + case r == '"': + // Quote opening or closing. + curr = QUO + case !ctx.quo: + // Not inside quoted string, test for control characters. + if r == ' ' || r == '\t' { + // Tab or space. + curr = AND + } else if r == ',' { + curr = OR + } + } + + if curr == QUO { + if ctx.quo { + // End of the quoted string. Close the quote. + ctx.quo = false + } else { + if prev == ORD { + // Reject strings like a"b + return nil, nil, fmt.Errorf("missing operator at or near %d", pos) + } + // Start of the quoted string. Open the quote. + ctx.quo = true + } + // Treat quoted string as ordinary. + curr = ORD + } + + // Parser: process the current lexem in context. + switch curr { + case OR: + if ctx.postOp == OR { + // More than one comma: ' , ,,' + return nil, nil, fmt.Errorf("invalid operator sequence at or near %d", pos) + } + // Ensure context is not "and", i.e. the case like ' ,' -> ',' + ctx.postOp = OR + if prev == ORD { + // Close the current token. + ctx.end = i + } + case AND: + if prev == ORD { + // Close the current token. + ctx.end = i + ctx.postOp = AND + } else if ctx.postOp != OR { + // "and" does not change the "or" context. + ctx.postOp = AND + } + case ORD: + if prev == OR || prev == AND { + // Ordinary character after a comma or a space: ' a' or ',a'. + // Emit without changing the operation. + emit = true + } + case END: + if prev == ORD { + // Close the current token. + ctx.end = i + } + emit = true + } + + if emit { + if ctx.quo && curr == END { + return nil, nil, fmt.Errorf("unterminated quoted string at or near %d %#v", pos, ctx) + } + + // Emit the new token. + op := ctx.preOp + if ctx.postOp == OR { + op = OR + } + start, end := ctx.start, ctx.end + if query[start] == '"' && query[end-1] == '"' { + start++ + end-- + } + // Add token if non-empty. + if start < end { + out = append(out, token{val: strings.ToLower(query[start:end]), op: op}) + } + ctx.start = i + ctx.preOp, ctx.postOp = ctx.postOp, NONE + } + + prev = curr + } + + if len(out) == 0 { + return nil, nil, nil + } + + // Convert tokens to two string slices. + var and []string + var or []string + for _, t := range out { + switch t.op { + case AND: + and = append(and, t.val) + case OR: + or = append(or, t.val) + } + } + return and, or, nil +} + +// Returns > 0 if v1 > v2; zero if equal; < 0 if v1 < v2 +// Only Major and Minor parts are compared, the trailer is ignored. +func versionCompare(v1, v2 int) int { + return (v1 >> 8) - (v2 >> 8) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// Truncate string if it's too long. Used in logging. +func truncateStringIfTooLong(s string) string { + if len(s) <= 1024 { + return s + } + + return s[:1024] + "..." +} + +// Convert relative filepath to absolute. +func toAbsolutePath(base, path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Clean(filepath.Join(base, path)) +} + +// Detect platform from the UserAgent string. +func platformFromUA(ua string) string { + ua = strings.ToLower(ua) + switch { + case strings.Contains(ua, "reactnative"): + switch { + case strings.Contains(ua, "iphone"), + strings.Contains(ua, "ipad"): + return "ios" + case strings.Contains(ua, "android"): + return "android" + } + return "" + case strings.Contains(ua, "tinodejs"): + return "web" + case strings.Contains(ua, "tindroid"): + return "android" + case strings.Contains(ua, "tinodios"): + return "ios" + } + return "" +} + +func parseTLSConfig(tlsEnabled bool, jsconfig json.RawMessage) (*tls.Config, error) { + type tlsAutocertConfig struct { + // Domains to support by autocert + Domains []string `json:"domains"` + // Name of directory where auto-certificates are cached, e.g. /etc/letsencrypt/live/your-domain-here + CertCache string `json:"cache"` + // Contact email for letsencrypt + Email string `json:"email"` + } + + type tlsConfig struct { + // Flag enabling TLS + Enabled bool `json:"enabled"` + // Listen for connections on this address:port and redirect them to HTTPS port. + RedirectHTTP string `json:"http_redirect"` + // Enable Strict-Transport-Security by setting max_age > 0 + StrictMaxAge int `json:"strict_max_age"` + // ACME autocert config, e.g. letsencrypt.org + Autocert *tlsAutocertConfig `json:"autocert"` + // If Autocert is not defined, provide file names of static certificate and key + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + } + + var config tlsConfig + + if jsconfig != nil { + if err := json.Unmarshal(jsconfig, &config); err != nil { + return nil, errors.New("http: failed to parse tls_config: " + err.Error() + "(" + string(jsconfig) + ")") + } + } + + if !tlsEnabled && !config.Enabled { + return nil, nil + } + + if config.StrictMaxAge > 0 { + globals.tlsStrictMaxAge = strconv.Itoa(config.StrictMaxAge) + } + + globals.tlsRedirectHTTP = config.RedirectHTTP + + // If autocert is provided, use it. + if config.Autocert != nil { + certManager := autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(config.Autocert.Domains...), + Cache: autocert.DirCache(config.Autocert.CertCache), + Email: config.Autocert.Email, + } + return certManager.TLSConfig(), nil + } + + // Otherwise try to use static keys. + cert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile) + if err != nil { + return nil, err + } + + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil +} + +// Merge source interface{} into destination interface. +// If values are maps,deep-merge them. Otherwise shallow-copy. +// Returns dst, true if the dst value was changed. +func mergeInterfaces(dst, src any) (any, bool) { + var changed bool + + if src == nil { + return dst, changed + } + + vsrc := reflect.ValueOf(src) + switch vsrc.Kind() { + case reflect.Map: + if xsrc, ok := src.(map[string]any); ok { + xdst, _ := dst.(map[string]any) + dst, changed = mergeMaps(xdst, xsrc) + } else { + changed = true + dst = src + } + case reflect.String: + if vsrc.String() == nullValue { + changed = dst != nil + dst = nil + } else { + changed = true + dst = src + } + default: + changed = true + dst = src + } + return dst, changed +} + +// Deep copy maps. +func mergeMaps(dst, src map[string]any) (map[string]any, bool) { + var changed bool + + if len(src) == 0 { + return dst, changed + } + + if dst == nil { + dst = make(map[string]any) + } + + for key, val := range src { + xval := reflect.ValueOf(val) + switch xval.Kind() { + case reflect.Map: + if xsrc, _ := val.(map[string]any); xsrc != nil { + // Deep-copy map[string]any + xdst, _ := dst[key].(map[string]any) + var lchange bool + dst[key], lchange = mergeMaps(xdst, xsrc) + changed = changed || lchange + } else if val != nil { + // The map is shallow-copied if it's not of the type map[string]any + dst[key] = val + changed = true + } + case reflect.String: + changed = true + if xval.String() == nullValue { + delete(dst, key) + } else if val != nil { + dst[key] = val + } + default: + if val != nil { + dst[key] = val + changed = true + } + } + } + + return dst, changed +} + +// Shallow copy of a map +func copyMap(src map[string]any) map[string]any { + dst := make(map[string]any, len(src)) + maps.Copy(dst, src) + return dst +} + +// netListener creates net.Listener for tcp and unix domains: +// if addr is in the form "unix:/run/tinode.sock" it's a unix socket, otherwise TCP host:port. +func netListener(addr string) (net.Listener, error) { + addrParts := strings.SplitN(addr, ":", 2) + if len(addrParts) == 2 && addrParts[0] == "unix" { + return net.Listen("unix", addrParts[1]) + } + return net.Listen("tcp", addr) +} + +// Check if specified address is a unix socket like "unix:/run/tinode.sock". +func isUnixAddr(addr string) bool { + addrParts := strings.SplitN(addr, ":", 2) + return len(addrParts) == 2 && addrParts[0] == "unix" +} + +var privateIPBlocks []*net.IPNet + +func isRoutableIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + + if privateIPBlocks == nil { + for _, cidr := range []string{ + "10.0.0.0/8", // RFC1918 + "172.16.0.0/12", // RFC1918 + "192.168.0.0/16", // RFC1918 + "fc00::/7", // RFC4193, IPv6 unique local addr + } { + _, block, _ := net.ParseCIDR(cidr) + privateIPBlocks = append(privateIPBlocks, block) + } + } + + for _, block := range privateIPBlocks { + if block.Contains(ip) { + return false + } + } + return true +} diff --git a/server/utils_test.go b/server/utils_test.go index e69de29..067e91e 100644 --- a/server/utils_test.go +++ b/server/utils_test.go @@ -0,0 +1,532 @@ +package main + +import ( + "reflect" + "strings" + "testing" + + "github.com/tinode/chat/server/store/types" +) + +func slicesEqual(expected, gotten []string) bool { + if len(expected) != len(gotten) { + return false + } + for i, v := range expected { + if v != gotten[i] { + return false + } + } + return true +} + +func expectSlicesEqual(t *testing.T, name string, expected, gotten []string) { + if !slicesEqual(expected, gotten) { + e := "'" + strings.Join(expected, "','") + "'" + g := "'" + strings.Join(gotten, "','") + "'" + t.Errorf("%s: expected %+v, got %+v", name, e, g) + } +} + +func TestStringSliceDelta(t *testing.T) { + // Case format: + // - inputs: old, new + // - expected outputs: added, removed, intersection + cases := [][5][]string{ + { + {"abc", "def", "fff"}, {}, + {}, {"abc", "def", "fff"}, {}, + }, + { + {}, {}, {}, {}, {}, + }, + { + {"aa", "xx", "bb", "aa", "bb"}, {"yy", "aa"}, + {"yy"}, {"aa", "bb", "bb", "xx"}, {"aa"}, + }, + { + {"bb", "aa", "bb"}, {"yy", "aa", "bb", "zzz", "zzz", "cc"}, + {"cc", "yy", "zzz", "zzz"}, {"bb"}, {"aa", "bb"}, + }, + { + {"aa", "aa", "aa"}, {"aa", "aa", "aa"}, + {}, {}, {"aa", "aa", "aa"}, + }, + } + + for _, tc := range cases { + added, removed, both := stringSliceDelta( + tc[0], tc[1], + ) + expectSlicesEqual(t, "added", tc[2], added) + expectSlicesEqual(t, "removed", tc[3], removed) + expectSlicesEqual(t, "both", tc[4], both) + + } +} + +func TestParseSearchQuery(t *testing.T) { + cases := []struct { + query string + expectedAnd []string + expectedOr []string + expectError bool + }{ + { + query: `tag1 tag2 tag3`, + expectedAnd: []string{"tag1", "tag2", "tag3"}, + expectedOr: []string{}, + expectError: false, + }, + { + query: `tag1,tag2,tag3`, + expectedAnd: []string{}, + expectedOr: []string{"tag1", "tag2", "tag3"}, + expectError: false, + }, + { + query: `tag1 tag2,tag3`, + expectedAnd: []string{"tag1"}, + expectedOr: []string{"tag2", "tag3"}, + expectError: false, + }, + { + query: `tag1 tag2,tag3 "tag4 tag5"`, + expectedAnd: []string{"tag1", "tag4 tag5"}, + expectedOr: []string{"tag2", "tag3"}, + expectError: false, + }, + { + query: `tag1,tag2 tag3`, + expectedAnd: []string{"tag3"}, + expectedOr: []string{"tag1", "tag2"}, + expectError: false, + }, + { + query: `"tag1 tag2" tag3,tag4`, + expectedAnd: []string{"tag1 tag2"}, + expectedOr: []string{"tag3", "tag4"}, + expectError: false, + }, + { + query: `tag1 "tag2 tag3"`, + expectedAnd: []string{"tag1", "tag2 tag3"}, + expectedOr: []string{}, + expectError: false, + }, + { + query: `tag1, tag2, tag3`, + expectedAnd: []string{}, + expectedOr: []string{"tag1", "tag2", "tag3"}, + expectError: false, + }, + { + query: `tag1 , tag2 ,tag3`, + expectedAnd: []string{}, + expectedOr: []string{"tag1", "tag2", "tag3"}, + expectError: false, + }, + { + query: `tag1 , tag2 tag3`, + expectedAnd: []string{"tag3"}, + expectedOr: []string{"tag1", "tag2"}, + expectError: false, + }, + { + query: `tag1 "unterminated quote`, + expectedAnd: nil, + expectedOr: nil, + expectError: true, + }, + { + query: `tag1,,tag2`, + expectedAnd: nil, + expectedOr: nil, + expectError: true, + }, + { + query: `tag1 "tag2" tag3`, + expectedAnd: []string{"tag1", "tag2", "tag3"}, + expectedOr: []string{}, + expectError: false, + }, + { + query: `tag1"tag2" quote in the middle`, + expectedAnd: nil, + expectedOr: nil, + expectError: true, + }, + } + + for _, tc := range cases { + and, or, err := parseSearchQuery(tc.query) + if tc.expectError { + if err == nil { + t.Errorf("expected error for query '%s', got none", tc.query) + } + } else { + if err != nil { + t.Errorf("unexpected error for query '%s': %v", tc.query, err) + } else { + expectSlicesEqual(t, tc.query+" AND", tc.expectedAnd, and) + expectSlicesEqual(t, tc.query+" OR", tc.expectedOr, or) + } + } + } +} + +func TestNormalizeTags(t *testing.T) { + cases := []struct { + input []string + expected types.StringSlice + }{ + { + input: []string{" Tag1 ", "tag2", "TAG3", "tag1"}, + expected: types.StringSlice{"tag1", "tag2", "tag3"}, + }, + { + input: []string{" ", "tag2", "TAG3", "tag1"}, + expected: types.StringSlice{"tag1", "tag2", "tag3"}, + }, + { + input: []string{"tag1"}, + expected: types.StringSlice{"tag1"}, + }, + { + input: []string{"tag1", nullValue}, + expected: []string{}, + }, + { + input: []string{}, + expected: nil, + }, + } + + for _, tc := range cases { + got := normalizeTags(tc.input, 16) + expectSlicesEqual(t, "normalizeTags", tc.expected, got) + } +} + +func TestRestrictedTagsEqual(t *testing.T) { + cases := []struct { + oldTags []string + newTags []string + namespaces map[string]bool + expected bool + }{ + { + oldTags: []string{"ns1:tag1", "ns2:tag2"}, + newTags: []string{"ns1:tag1", "ns2:tag2"}, + namespaces: map[string]bool{"ns1": true, "ns2": true}, + expected: true, + }, + { + oldTags: []string{"ns1:tag1", "ns2:tag2"}, + newTags: []string{"ns1:tag1", "ns2:tag3"}, + namespaces: map[string]bool{"ns1": true, "ns2": true}, + expected: false, + }, + { + oldTags: []string{"ns1:tag1", "ns2:tag2"}, + newTags: []string{"ns1:tag1"}, + namespaces: map[string]bool{"ns1": true, "ns2": true}, + expected: false, + }, + } + + for _, tc := range cases { + got := restrictedTagsEqual(tc.oldTags, tc.newTags, tc.namespaces) + if got != tc.expected { + t.Errorf("restrictedTagsEqual: expected %v, got %v", tc.expected, got) + } + } +} + +func TestIsNullValue(t *testing.T) { + cases := []struct { + input any + expected bool + }{ + { + input: nullValue, + expected: true, + }, + { + input: "some string", + expected: false, + }, + { + input: 123, + expected: false, + }, + { + input: nil, + expected: false, + }, + } + + for _, tc := range cases { + got := isNullValue(tc.input) + if got != tc.expected { + t.Errorf("isNullValue: expected %v, got %v", tc.expected, got) + } + } +} + +func TestParseVersion(t *testing.T) { + cases := []struct { + input string + expected int + }{ + { + input: "1.2.3", + expected: (1 << 16) | (2 << 8) | 3, + }, + { + input: "1.2", + expected: (1 << 16) | (2 << 8), + }, + { + input: "1", + expected: (1 << 16), + }, + { + input: "v1.2.3", + expected: (1 << 16) | (2 << 8) | 3, + }, + { + input: "v1.2", + expected: (1 << 16) | (2 << 8), + }, + { + input: "v1", + expected: (1 << 16), + }, + { + input: "1.2.3-rc1", + expected: (1 << 16) | (2 << 8) | 3, + }, + { + input: "v1.2.3-rc1", + expected: (1 << 16) | (2 << 8) | 3, + }, + { + input: "0.0.0", + expected: 0, + }, + { + input: "v0.0.0", + expected: 0, + }, + { + input: "1.2.8192", // 8192 is greater than 0x1fff, should be ignored + expected: (1 << 16) | (2 << 8), + }, + { + input: "1.8192.3", // 8192 is greater than 0x1fff, should be ignored + expected: (1 << 16) | 3, + }, + { + input: "8192.2.3", // 8192 is greater than 0x1fff, should be ignored + expected: (2 << 8) | 3, + }, + { + input: "", + expected: 0, + }, + } + + for _, tc := range cases { + got := parseVersion(tc.input) + if got != tc.expected { + t.Errorf("parseVersion(%q): expected %d, got %d", tc.input, tc.expected, got) + } + } +} + +func TestMergeMaps(t *testing.T) { + cases := []struct { + dst map[string]any + src map[string]any + expected map[string]any + changed bool + }{ + { + dst: map[string]any{"a": 1, "b": 2}, + src: map[string]any{"b": 3, "c": 4}, + expected: map[string]any{"a": 1, "b": 3, "c": 4}, + changed: true, + }, + { + dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}}, + src: map[string]any{"b": map[string]any{"y": 2}}, + expected: map[string]any{"a": 1, "b": map[string]any{"x": 1, "y": 2}}, + changed: true, + }, + { + dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}}, + src: map[string]any{"b": map[string]any{"x": nullValue}}, + expected: map[string]any{"a": 1, "b": map[string]any{}}, + changed: true, + }, + { + dst: map[string]any{"a": 1, "b": 2}, + src: map[string]any{}, + expected: map[string]any{"a": 1, "b": 2}, + changed: false, + }, + { + dst: nil, + src: map[string]any{"a": 1}, + expected: map[string]any{"a": 1}, + changed: true, + }, + { + dst: map[string]any{"a": 1}, + src: map[string]any{"a": nullValue}, + expected: map[string]any{}, + changed: true, + }, + } + + for _, tc := range cases { + got, changed := mergeMaps(tc.dst, tc.src) + if !reflect.DeepEqual(got, tc.expected) || changed != tc.changed { + t.Errorf("mergeMaps(%v, %v): expected (%v, %v), got (%v, %v)", tc.dst, tc.src, tc.expected, tc.changed, got, changed) + } + } +} +func TestMergeInterfaces(t *testing.T) { + cases := []struct { + dst any + src any + expected any + changed bool + }{ + { + dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}}, + src: map[string]any{"b": map[string]any{"y": 2}}, + expected: map[string]any{"a": 1, "b": map[string]any{"x": 1, "y": 2}}, + changed: true, + }, + { + dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}}, + src: map[string]any{"b": map[string]any{"x": nullValue}}, + expected: map[string]any{"a": 1, "b": map[string]any{}}, + changed: true, + }, + { + dst: map[string]any{"a": 1}, + src: nullValue, + expected: nil, + changed: true, + }, + { + dst: "old string", + src: "new string", + expected: "new string", + changed: true, + }, + { + dst: "old string", + src: 12345, + expected: 12345, + changed: true, + }, + { + dst: "old string", + src: nullValue, + expected: nil, + changed: true, + }, + { + dst: 123, + src: 456, + expected: 456, + changed: true, + }, + { + dst: 123, + src: nil, + expected: 123, + changed: false, + }, + { + dst: 123, + src: true, + expected: true, + changed: true, + }, + { + dst: []string{"a", "b", "c"}, + src: []string{"d", "e", "f"}, + expected: []string{"d", "e", "f"}, + changed: true, + }, + } + + for _, tc := range cases { + got, changed := mergeInterfaces(tc.dst, tc.src) + if !reflect.DeepEqual(got, tc.expected) || changed != tc.changed { + t.Errorf("mergeInterfaces(%v, %v): expected (%v, %v), got (%v, %v)", tc.dst, tc.src, tc.expected, tc.changed, got, changed) + } + } +} + +func TestFilterTags(t *testing.T) { + cases := []struct { + tags []string + ns map[string]bool + expected []string + }{ + { + tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "}, + ns: map[string]bool{"ns1": true, "ns2": false, "xtra": true}, + expected: []string{"ns1:tag1"}, + }, + { + tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "}, + ns: map[string]bool{}, + expected: nil, + }, + } + + for _, tc := range cases { + got := filterTags(tc.tags, tc.ns) + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("filterTags(%v, %v): expected (%v), got (%v)", tc.tags, tc.ns, tc.expected, got) + } + } +} + +func TestHasDuplicateNamespaceTags(t *testing.T) { + cases := []struct { + tags []string + ns string + expected bool + }{ + { + tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "}, + ns: "ns1", + expected: false, + }, + { + tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: ", "ns1:tag2"}, + ns: "ns1", + expected: true, + }, + { + tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "}, + ns: "", + expected: false, + }, + } + + for _, tc := range cases { + got := hasDuplicateNamespaceTags(tc.tags, tc.ns) + if !reflect.DeepEqual(got, tc.expected) { + t.Errorf("filterTags(%v, %v): expected (%v), got (%v)", tc.tags, tc.ns, tc.expected, got) + } + } +} diff --git a/server/validate/email/validate.go b/server/validate/email/validate.go index e69de29..a9a5d45 100644 --- a/server/validate/email/validate.go +++ b/server/validate/email/validate.go @@ -0,0 +1,553 @@ +// Package email is a credential validator which uses an external SMTP server. +package email + +import ( + "bytes" + crand "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "math/rand" + "mime" + qp "mime/quotedprintable" + "net/mail" + "net/smtp" + "net/url" + "strconv" + "strings" + textt "text/template" + + "slices" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" + "github.com/tinode/chat/server/validate" + i18n "golang.org/x/text/language" +) + +// Validator configuration. +type validator struct { + // Base URL of the web client. + HostUrl string `json:"host_url"` + // List of languages supported by templates. + Languages []string `json:"languages"` + // Path to email validation templates, either a template itself or a literal string. + ValidationTemplFile string `json:"validation_templ"` + // Path to templates for resetting the authentication secret. + ResetTemplFile string `json:"reset_secret_templ"` + // Sender RFC 5322 email address. + SendFrom string `json:"sender"` + // Login to use for SMTP authentication. + Login string `json:"login"` + // Password to use for SMTP authentication. + SenderPassword string `json:"sender_password"` + // Authentication mechanism to use, optional. One of "login", "md5", "plain" (default). + AuthMechanism string `json:"auth_mechanism"` + // Optional response which bypasses the validation. + DebugResponse string `json:"debug_response"` + // Number of validation attempts before email is locked. + MaxRetries int `json:"max_retries"` + // Address of the SMTP server. + SMTPAddr string `json:"smtp_server"` + // Port of the SMTP server. + SMTPPort string `json:"smtp_port"` + // ServerName used in SMTP HELO/EHLO command. + SMTPHeloHost string `json:"smtp_helo_host"` + // Skip verification of the server's certificate chain and host name. + // In this mode, TLS is susceptible to machine-in-the-middle attacks. + TLSInsecureSkipVerify bool `json:"insecure_skip_verify"` + // Optional whitelist of email domains accepted for registration. + Domains []string `json:"domains"` + // Length of secret numeric code to sent for validation. + CodeLength int `json:"code_length"` + + // Must use index into language array instead of language tags because language.Matcher is brain damaged: + // https://github.com/golang/go/issues/24211 + validationTempl []*textt.Template + resetTempl []*textt.Template + auth smtp.Auth + senderEmail string + langMatcher i18n.Matcher + maxCodeValue *big.Int +} + +const ( + validatorName = "email" + + defaultMaxRetries = 3 + defaultPort = "25" + + // Technically email could be up to 255 bytes long but practically 128 is enough. + maxEmailLength = 128 + + // Default code length when one is not provided in the config + defaultCodeLength = 6 +) + +// Email template parts +var templateParts = []string{"subject", "body_plain", "body_html"} + +// Init: initialize validator. +func (v *validator) Init(jsonconf string) error { + if err := json.Unmarshal([]byte(jsonconf), v); err != nil { + return err + } + + sender, err := mail.ParseAddress(v.SendFrom) + if err != nil { + return err + } + v.senderEmail = sender.Address + + // Enable auth if login is provided. + if v.Login != "" { + mechanism := strings.ToLower(v.AuthMechanism) + switch mechanism { + case "cram-md5": + v.auth = smtp.CRAMMD5Auth(v.Login, v.SenderPassword) + case "login": + v.auth = &loginAuth{[]byte(v.Login), []byte(v.SenderPassword)} + case "", "plain": + v.auth = smtp.PlainAuth("", v.Login, v.SenderPassword, v.SMTPAddr) + default: + return errors.New("unknown auth_mechanism") + } + } + + // Optionally resolve paths. + v.ValidationTemplFile, err = validate.ResolveTemplatePath(v.ValidationTemplFile) + if err != nil { + return err + } + v.ResetTemplFile, err = validate.ResolveTemplatePath(v.ResetTemplFile) + if err != nil { + return err + } + + // Paths to templates could be templates themselves: they may be language-dependent. + var validationPathTempl, resetPathTempl *textt.Template + validationPathTempl, err = textt.New("validation").Parse(v.ValidationTemplFile) + if err != nil { + return err + } + resetPathTempl, err = textt.New("reset").Parse(v.ResetTemplFile) + if err != nil { + return err + } + + var path string + if len(v.Languages) > 0 { + v.validationTempl = make([]*textt.Template, len(v.Languages)) + v.resetTempl = make([]*textt.Template, len(v.Languages)) + var langTags []i18n.Tag + // Find actual content templates for each defined language. + for idx, lang := range v.Languages { + tag, err := i18n.Parse(lang) + if err != nil { + return err + } + langTags = append(langTags, tag) + if v.validationTempl[idx], path, err = validate.ReadTemplateFile(validationPathTempl, lang); err != nil { + return err + } + if err = isTemplateValid(v.validationTempl[idx]); err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + + if v.resetTempl[idx], path, err = validate.ReadTemplateFile(resetPathTempl, lang); err != nil { + return err + } + if err = isTemplateValid(v.resetTempl[idx]); err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + } + v.langMatcher = i18n.NewMatcher(langTags) + } else { + v.validationTempl = make([]*textt.Template, 1) + v.resetTempl = make([]*textt.Template, 1) + // No i18n support. Use defaults. + v.validationTempl[0], path, err = validate.ReadTemplateFile(validationPathTempl, "") + if err != nil { + return err + } + if err = isTemplateValid(v.validationTempl[0]); err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + + v.resetTempl[0], path, err = validate.ReadTemplateFile(resetPathTempl, "") + if err != nil { + return err + } + if err = isTemplateValid(v.resetTempl[0]); err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + } + + if v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil { + return err + } + + if v.SMTPHeloHost == "" { + hostUrl, _ := url.Parse(v.HostUrl) + v.SMTPHeloHost = hostUrl.Hostname() + } + if v.SMTPHeloHost == "" { + return errors.New("missing SMTP host") + } + + if v.MaxRetries == 0 { + v.MaxRetries = defaultMaxRetries + } + if v.CodeLength == 0 { + v.CodeLength = defaultCodeLength + } + v.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil) + + if v.SMTPPort == "" { + v.SMTPPort = defaultPort + } + + return nil +} + +// IsInitialized returns true if the validator is initialized. +func (v *validator) IsInitialized() bool { + return v.SMTPHeloHost != "" +} + +// PreCheck validates the credential and parameters without sending an email. +// If the credential is valid, it's returned with an appropriate prefix. +func (v *validator) PreCheck(cred string, _ map[string]any) (string, error) { + if len(cred) > maxEmailLength { + return "", t.ErrMalformed + } + + // The email must be plain user@domain. + addr, err := mail.ParseAddress(cred) + if err != nil || addr.Address != cred { + return "", t.ErrMalformed + } + + // Normalize email to make sure Unicode case collisions don't lead to security problems. + addr.Address = strings.ToLower(addr.Address) + + // If a whitelist of domains is provided, make sure the email belongs to the list. + if len(v.Domains) > 0 { + // Parse email into user and domain parts. + parts := strings.Split(addr.Address, "@") + if len(parts) != 2 { + return "", t.ErrMalformed + } + + if !slices.Contains(v.Domains, parts[1]) { + return "", t.ErrPolicy + } + } + + return validatorName + ":" + addr.Address, nil +} + +// Send a request for confirmation to the user: makes a record in DB and nothing else. +func (v *validator) Request(tenantID t.TenantID, user t.Uid, email, lang, resp string, tmpToken []byte) (bool, error) { + // Email validator cannot accept an immediate response. + if resp != "" { + return false, t.ErrFailed + } + + // Normalize email to make sure Unicode case collisions don't lead to security problems. + email = strings.ToLower(email) + + token := make([]byte, base64.StdEncoding.EncodedLen(len(tmpToken))) + base64.StdEncoding.Encode(token, tmpToken) + + // Generate expected response as a random numeric string between 0 and 999999. + code, err := crand.Int(crand.Reader, v.maxCodeValue) + if err != nil { + return false, err + } + resp = strconv.FormatInt(code.Int64(), 10) + resp = strings.Repeat("0", v.CodeLength-len(resp)) + resp + + var template *textt.Template + if v.langMatcher != nil { + // Find the template for the requested language. + // Make sure the language tag is standardized. Matcher is a bit dumber than Parse(). + normalized, _ := i18n.Parse(lang) + // The matched tag is usually not in the list of available languages (e.g. es_ES -> es-u-rg-eszzzz). + // Use index to find the template instead of tag. + _, idx := i18n.MatchStrings(v.langMatcher, normalized.String()) + template = v.validationTempl[idx] + } else { + template = v.validationTempl[0] + } + + content, err := validate.ExecuteTemplate(template, templateParts, map[string]any{ + "Token": url.QueryEscape(string(token)), + "Code": resp, + "HostUrl": v.HostUrl}) + if err != nil { + return false, err + } + + // Create or update validation record in DB. + isNew, err := store.Users.UpsertCred(tenantID, &t.Credential{ + User: user.String(), + Method: validatorName, + Value: email, + Resp: resp}) + if err != nil { + return false, err + } + + // Send email without blocking. Email sending may take long time. + go v.send(email, content) + + return isNew, nil +} + +// ResetSecret sends a message with instructions for resetting an authentication secret. +func (v *validator) ResetSecret(tenantID t.TenantID, email, scheme, lang string, code []byte, params map[string]any) error { + // Normalize email to make sure Unicode case collisions don't lead to security problems. + email = strings.ToLower(email) + + var template *textt.Template + if v.langMatcher != nil { + _, idx := i18n.MatchStrings(v.langMatcher, lang) + template = v.resetTempl[idx] + } else { + template = v.resetTempl[0] + } + + var login string + if params != nil { + // Invariant: params["login"] is a string. Will panic if the invariant doesn't hold. + login = params["login"].(string) + } + + content, err := validate.ExecuteTemplate(template, templateParts, map[string]any{ + "Login": login, + "Code": string(code), + "Cred": email, + "Scheme": scheme, + "HostUrl": v.HostUrl}) + if err != nil { + return err + } + + // Send email without blocking. Email sending may take long time. + go v.send(email, content) + + return nil +} + +// Check checks if the provided validation response matches the expected response. +// Returns the value of validated credential on success. +func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) { + cred, err := store.Users.GetActiveCred(tenantID, user, validatorName) + if err != nil { + return "", err + } + + if cred == nil { + // Request to validate non-existent credential. + return "", t.ErrNotFound + } + + if cred.Retries > v.MaxRetries { + return "", t.ErrPolicy + } + + if resp == "" { + return "", t.ErrCredentials + } + + // Comparing with dummy response too. + if cred.Resp == resp || v.DebugResponse == resp { + // Valid response, save confirmation. + return cred.Value, store.Users.ConfirmCred(tenantID, user, validatorName) + } + + // Invalid response, increment fail counter, ignore possible error. + store.Users.FailCred(tenantID, user, validatorName) + + return "", t.ErrCredentials +} + +// Delete deletes user's records. +func (v *validator) Delete(tenantID t.TenantID, user t.Uid) error { + return store.Users.DelCred(tenantID, user, validatorName, "") +} + +// Remove deactivates or removes user's credential. +func (v *validator) Remove(tenantID t.TenantID, user t.Uid, value string) error { + return store.Users.DelCred(tenantID, user, validatorName, value) +} + +// TempAuthScheme returns a temporary authentication method used by this validator. +func (v *validator) TempAuthScheme() (string, error) { + return "code", nil +} + +// SendMail replacement +func (v *validator) sendMail(rcpt []string, msg []byte) error { + client, err := smtp.Dial(v.SMTPAddr + ":" + v.SMTPPort) + if err != nil { + return err + } + defer client.Close() + if err = client.Hello(v.SMTPHeloHost); err != nil { + return err + } + if istls, _ := client.Extension("STARTTLS"); istls { + tlsConfig := &tls.Config{ + InsecureSkipVerify: v.TLSInsecureSkipVerify, + ServerName: v.SMTPAddr, + } + if err = client.StartTLS(tlsConfig); err != nil { + return err + } + } + if v.auth != nil { + if isauth, _ := client.Extension("AUTH"); isauth { + err = client.Auth(v.auth) + if err != nil { + return err + } + } + } + if err = client.Mail(strings.ReplaceAll(strings.ReplaceAll(v.senderEmail, "\r", " "), "\n", " ")); err != nil { + return err + } + for _, to := range rcpt { + if err = client.Rcpt(strings.ReplaceAll(strings.ReplaceAll(to, "\r", " "), "\n", " ")); err != nil { + return err + } + } + w, err := client.Data() + if err != nil { + return err + } + if _, err = w.Write(msg); err != nil { + return err + } + if err = w.Close(); err != nil { + return err + } + return client.Quit() +} + +// This is a basic SMTP sender which connects to a server using login/password. +// - +// See here how to send email from Amazon SES: +// https://docs.aws.amazon.com/sdk-for-go/api/service/ses/#example_SES_SendEmail_shared00 +// - +// Mailjet and SendGrid have some free email limits. +func (v *validator) send(to string, content map[string]string) error { + message := &bytes.Buffer{} + + // Common headers. + fmt.Fprintf(message, "From: %s\r\n", v.SendFrom) + fmt.Fprintf(message, "To: %s\r\n", to) + message.WriteString("Subject: ") + // Old email clients may barf on UTF-8 strings. + // Encode as quoted printable with 75-char strings separated by spaces, split by spaces, reassemble. + message.WriteString(strings.Join(strings.Split(mime.QEncoding.Encode("utf-8", content["subject"]), " "), "\r\n ")) + message.WriteString("\r\n") + message.WriteString("MIME-version: 1.0;\r\n") + + if content["body_html"] == "" { + // Plain text message + message.WriteString("Content-Type: text/plain; charset=\"UTF-8\"; format=flowed; delsp=yes\r\n") + message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + b64w := base64.NewEncoder(base64.StdEncoding, message) + b64w.Write([]byte(content["body_plain"])) + b64w.Close() + } else if content["body_plain"] == "" { + // HTML-formatted message + message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n") + message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qpw := qp.NewWriter(message) + qpw.Write([]byte(content["body_html"])) + qpw.Close() + } else { + // Multipart-alternative message includes both HTML and plain text components. + boundary := randomBoundary() + message.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n") + + message.WriteString("--" + boundary + "\r\n") + message.WriteString("Content-Type: text/plain; charset=\"UTF-8\"; format=flowed; delsp=yes\r\n") + message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + b64w := base64.NewEncoder(base64.StdEncoding, message) + b64w.Write([]byte(content["body_plain"])) + b64w.Close() + + message.WriteString("\r\n") + + message.WriteString("--" + boundary + "\r\n") + message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n") + message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qpw := qp.NewWriter(message) + qpw.Write([]byte(content["body_html"])) + qpw.Close() + + message.WriteString("\r\n--" + boundary + "--") + } + + message.WriteString("\r\n") + + err := v.sendMail([]string{to}, message.Bytes()) + if err != nil { + logs.Warn.Println("SMTP error", to, err) + } + + return err +} + +// Check if the template contains all required parts. +func isTemplateValid(templ *textt.Template) error { + if templ.Lookup("subject") == nil { + return fmt.Errorf("template invalid: '%s' not found", "subject") + } + if templ.Lookup("body_plain") == nil && templ.Lookup("body_html") == nil { + return fmt.Errorf("template invalid: neither of '%s', '%s' is found", "body_plain", "body_html") + } + return nil +} + +type loginAuth struct { + username, password []byte +} + +// Start begins an authentication with a server. Exported only to satisfy the interface definition. +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte(a.username), nil +} + +// Next continues the authentication. Exported only to satisfy the interface definition. +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch strings.ToLower(string(fromServer)) { + case "username:": + return a.username, nil + case "password:": + return a.password, nil + default: + return nil, fmt.Errorf("LOGIN AUTH unknown server response '%s'", string(fromServer)) + } + } + return nil, nil +} + +func randomBoundary() string { + var buf [24]byte + rand.Read(buf[:]) + return fmt.Sprintf("tinode--%x", buf[:]) +} + +func init() { + store.RegisterValidator(validatorName, &validator{}) +} diff --git a/server/validate/tel/validate.go b/server/validate/tel/validate.go index e69de29..ebb5998 100644 --- a/server/validate/tel/validate.go +++ b/server/validate/tel/validate.go @@ -0,0 +1,278 @@ +// Package tel is an incomplete implementation of SMS or voice credential validator. +package tel + +import ( + "crypto/rand" + "encoding/json" + "math/big" + "strconv" + "strings" + textt "text/template" + + "github.com/nyaruka/phonenumbers" + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" + "github.com/tinode/chat/server/validate" + i18n "golang.org/x/text/language" +) + +// Empty placeholder struct. +type validator struct { + // Base URL of the web client to tell clients. + HostUrl string `json:"host_url"` + // List of languages supported by templates. + Languages []string `json:"languages"` + // Path to email validation and password reset templates, either a template itself or a literal string. + UniversalTemplFile string `json:"universal_templ"` + // Sender address (phone number). + Sender string `json:"sender"` + // Debug response to accept during testing. + DebugResponse string `json:"debug_response"` + // Maximum number of validation retires. + MaxRetries int `json:"max_retries"` + // Length of secret numeric code to sent for validation. + CodeLength int `json:"code_length"` + // Twilio-specific config. + Twilio json.RawMessage `json:"twilio_conf"` + + // Must use index into language array instead of language tags because language.Matcher is brain damaged: + // https://github.com/golang/go/issues/24211 + universalTempl []*textt.Template + langMatcher i18n.Matcher + maxCodeValue *big.Int +} + +const ( + validatorName = "tel" + + defaultMaxRetries = 3 + + // Default code length when one is not provided in the config + defaultCodeLength = 6 + + defaultSender = "Tinode" +) + +func (v *validator) Init(jsonconf string) error { + var err error + + if err = json.Unmarshal([]byte(jsonconf), v); err != nil { + return err + } + + if v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil { + return err + } + + var universalPathTempl *textt.Template + universalPathTempl, err = textt.New("universal").Parse(v.UniversalTemplFile) + if err != nil { + return err + } + + if len(v.Languages) > 0 { + v.universalTempl = make([]*textt.Template, len(v.Languages)) + var langTags []i18n.Tag + // Find actual content templates for each defined language. + for idx, lang := range v.Languages { + tag, err := i18n.Parse(lang) + if err != nil { + return err + } + langTags = append(langTags, tag) + if v.universalTempl[idx], _, err = validate.ReadTemplateFile(universalPathTempl, lang); err != nil { + return err + } + } + v.langMatcher = i18n.NewMatcher(langTags) + } else { + v.universalTempl = make([]*textt.Template, 1) + // No i18n support. Use defaults. + v.universalTempl[0], _, err = validate.ReadTemplateFile(universalPathTempl, "") + if err != nil { + return err + } + } + + if v.Twilio != nil { + if err = twilioInit(v.Twilio); err != nil { + return err + } + } + + if v.Sender == "" { + v.Sender = defaultSender + } + if v.MaxRetries == 0 { + v.MaxRetries = defaultMaxRetries + } + if v.CodeLength == 0 { + v.CodeLength = defaultCodeLength + } + v.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil) + + return nil +} + +// IsInitialized returns true if the validator is initialized. +func (v *validator) IsInitialized() bool { + return v.CodeLength > 0 +} + +// PreCheck validates the credential and parameters without sending an SMS or making the call. +// If credential is valid, it's formatted and prefixed with a tag namespace. +func (*validator) PreCheck(cred string, params map[string]any) (string, error) { + // Parse will try to extract the number from any text, make sure it's just the number. + if !phonenumbers.VALID_PHONE_NUMBER_PATTERN.MatchString(cred) { + return "", t.ErrMalformed + } + countryCode, ok := params["countryCode"].(string) + if !ok { + countryCode = "US" + } + number, err := phonenumbers.Parse(cred, countryCode) + if err != nil { + return "", t.ErrMalformed + } + if !phonenumbers.IsValidNumber(number) { + return "", t.ErrMalformed + } + if numType := phonenumbers.GetNumberType(number); numType != phonenumbers.FIXED_LINE_OR_MOBILE && + numType != phonenumbers.MOBILE { + return "", t.ErrMalformed + } + return validatorName + ":" + phonenumbers.Format(number, phonenumbers.E164), nil +} + +// Request sends a request for confirmation to the user: makes a record in DB and nothing else. +func (v *validator) Request(tenantID t.TenantID, user t.Uid, phone, lang, resp string, tmpToken []byte) (bool, error) { + // Phone validator cannot accept an immediate response. + if resp != "" { + return false, t.ErrFailed + } + + // Generate expected response as a random numeric string between 0 and 999999. + code, err := rand.Int(rand.Reader, v.maxCodeValue) + if err != nil { + return false, err + } + resp = strconv.FormatInt(code.Int64(), 10) + resp = strings.Repeat("0", v.CodeLength-len(resp)) + resp + + var template *textt.Template + if v.langMatcher != nil { + _, idx := i18n.MatchStrings(v.langMatcher, lang) + template = v.universalTempl[idx] + } else { + template = v.universalTempl[0] + } + + content, err := validate.ExecuteTemplate(template, nil, map[string]any{ + "Code": resp, + "HostUrl": v.HostUrl}) + if err != nil { + return false, err + } + + // Create or update validation record in DB. + isNew, err := store.Users.UpsertCred(tenantID, &t.Credential{ + User: user.String(), + Method: validatorName, + Value: phone, + Resp: resp}) + if err != nil { + return false, err + } + + // Send SMS without blocking. It sending may take long time. + go v.send(phone, content[""]) + + return isNew, nil +} + +// ResetSecret sends a message with instructions for resetting an authentication secret. +func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string, code []byte, params map[string]any) error { + var template *textt.Template + if v.langMatcher != nil { + _, idx := i18n.MatchStrings(v.langMatcher, lang) + template = v.universalTempl[idx] + } else { + template = v.universalTempl[0] + } + + content, err := validate.ExecuteTemplate(template, nil, map[string]any{ + "Code": string(code), + "HostUrl": v.HostUrl}) + if err != nil { + return err + } + + // Send SMS without blocking. Sending may take long time. + go v.send(phone, content[""]) + + return nil +} + +// Check checks validity of user's response. +func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) { + cred, err := store.Users.GetActiveCred(tenantID, user, validatorName) + if err != nil { + return "", err + } + + if cred == nil { + // Blank credential. + return "", t.ErrNotFound + } + + if cred.Retries > v.MaxRetries { + return "", t.ErrPolicy + } + + if resp == "" { + return "", t.ErrCredentials + } + + // Comparing with dummy response too. + if cred.Resp == resp || v.DebugResponse == resp { + // Valid response, save confirmation. + return cred.Value, store.Users.ConfirmCred(tenantID, user, validatorName) + } + + // Invalid response, increment fail counter, ignore possible error. + store.Users.FailCred(tenantID, user, validatorName) + + return "", t.ErrCredentials +} + +// Delete deletes user's records. Returns deleted credentials. +func (*validator) Delete(tenantID t.TenantID, user t.Uid) error { + return store.Users.DelCred(tenantID, user, validatorName, "") +} + +// Remove or disable the given record. +func (*validator) Remove(tenantID t.TenantID, user t.Uid, value string) error { + return store.Users.DelCred(tenantID, user, validatorName, value) +} + +// TempAuthScheme returns a temporary authentication method used by this validator. +func (v *validator) TempAuthScheme() (string, error) { + return "code", nil +} + +// Implement sending the SMS. +func (v *validator) send(to, body string) error { + if v.Twilio != nil { + if err := twilioSend(v.Sender, to, body); err != nil { + logs.Warn.Println("Twilio SMS error", to, err) + } + } else { + logs.Info.Println("Send SMS, To:", to, "\nText:", body) + } + return nil +} + +func init() { + store.RegisterValidator(validatorName, &validator{}) +} diff --git a/server/validate/validator.go b/server/validate/validator.go index b4d2fe9..c948f8c 100644 --- a/server/validate/validator.go +++ b/server/validate/validator.go @@ -33,7 +33,7 @@ type Validator interface { // lang: user's human language as repored in the session. // resp: optional response if user already has it (i.e. captcha/recaptcha). // tmpToken: temporary authentication token to include in the request. - Request(user t.Uid, cred, lang, resp string, tmpToken []byte) (bool, error) + Request(tenantID t.TenantID, user t.Uid, cred, lang, resp string, tmpToken []byte) (bool, error) // ResetSecret sends a message with instructions for resetting an authentication secret. // cred: address to use for the message. @@ -41,17 +41,17 @@ type Validator interface { // lang: human language as reported in the session. // tmpToken: temporary authentication token // params: authentication params. - ResetSecret(cred, scheme, lang string, tmpToken []byte, params map[string]any) error + ResetSecret(tenantID t.TenantID, cred, scheme, lang string, tmpToken []byte, params map[string]any) error // Check checks validity of user's response. // Returns the value of validated credential on success. - Check(user t.Uid, resp string) (string, error) + Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) // Remove deletes or deactivates user's given value. - Remove(user t.Uid, value string) error + Remove(tenantID t.TenantID, user t.Uid, value string) error // Delete deletes user's record. - Delete(user t.Uid) error + Delete(tenantID t.TenantID, user t.Uid) error // TempAuthScheme returns a temporary authentication method used by this validator. // It should be either "code" or "token". diff --git a/templ b/templ new file mode 120000 index 0000000..ef3d66d --- /dev/null +++ b/templ @@ -0,0 +1 @@ +./server/templ \ No newline at end of file diff --git a/templ/email-password-reset-en.templ b/templ/email-password-reset-en.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-es.templ b/templ/email-password-reset-es.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-fr.templ b/templ/email-password-reset-fr.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-pt.templ b/templ/email-password-reset-pt.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-ru.templ b/templ/email-password-reset-ru.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-uk.templ b/templ/email-password-reset-uk.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-vi.templ b/templ/email-password-reset-vi.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-zh-TW.templ b/templ/email-password-reset-zh-TW.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-password-reset-zh.templ b/templ/email-password-reset-zh.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-en.templ b/templ/email-validation-en.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-es.templ b/templ/email-validation-es.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-fr.templ b/templ/email-validation-fr.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-pt.templ b/templ/email-validation-pt.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-ru.templ b/templ/email-validation-ru.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-uk.templ b/templ/email-validation-uk.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-vi.templ b/templ/email-validation-vi.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-zh-TW.templ b/templ/email-validation-zh-TW.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/email-validation-zh.templ b/templ/email-validation-zh.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-en.templ b/templ/sms-universal-en.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-es.templ b/templ/sms-universal-es.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-fr.templ b/templ/sms-universal-fr.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-pt.templ b/templ/sms-universal-pt.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-ru.templ b/templ/sms-universal-ru.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-uk.templ b/templ/sms-universal-uk.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-vi.templ b/templ/sms-universal-vi.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-zh-TW.templ b/templ/sms-universal-zh-TW.templ deleted file mode 100644 index e69de29..0000000 diff --git a/templ/sms-universal-zh.templ b/templ/sms-universal-zh.templ deleted file mode 100644 index e69de29..0000000 diff --git a/tinode-db/README.md b/tinode-db/README.md index e69de29..b0cdb14 100644 --- a/tinode-db/README.md +++ b/tinode-db/README.md @@ -0,0 +1,54 @@ +# Utility to initialize or upgrade `tinode` DB + +This utility initializes the `tinode` database (or upgrades an existing DB from an earlier version) and optionally loads it with data. To force database reset use command line option `--reset=true`. + +## Build the package: + + - **RethinkDB** + `go build -tags rethinkdb` or `go build -i -tags rethinkdb` to automatically install missing dependencies. + + - **MySQL** + `go build -tags mysql` or `go build -i -tags mysql` to automatically install missing dependencies. + + - **MongoDB** + `go build -tags mongodb` or `go build -i -tags mongodb` to automatically install missing dependencies. + + - **PostgreSQL** + `go build -tags postgres` or `go build -i -tags postgres` to automatically install missing dependencies. + + +## Run + +Run from the command line. + +`tinode-db [parameters]` + +Command line parameters: + - `--reset`: delete the database then re-create it in a blank state; it has no effect if the database does not exist. + - `--upgrade`: upgrade database from an earlier version retaining all the data; make sure to backup the DB before upgrading. + - `--no_init`: check that database exists but don't create it if missing. + - `--data=FILENAME`: fill `tinode` database with data from the provided file. See [data.json](data.json). + - `--config=FILENAME`: load configuration from FILENAME. Example config is included as [tinode.conf](tinode.conf). + - `--make_root=USER_ID`: promote an existing user to root user, `USER_ID` of the form `usrAbCDef123`. + - `--add_root=USERNAME[:PASSWORD]`: create a new user account and make it root; if password is missing, a strong password will be generated. + +Configuration file options: + - `uid_key` is a base64-encoded 16 byte XTEA encryption key to (weakly) encrypt object IDs so they don't appear sequential. You probably want to use your own key in production. + - `store_config.adapters.mysql` and `store_config.adapters.rethinkdb` are database-specific sections: + - `database` is the name of the database to generate. + - `addresses` is RethinkDB/MongoDB's host and port number to connect to. An array of hosts can be provided as well `["host1", "host2"]`. + - `dsn` is MySQL's Data Source Name. + - `replica_set` is MongoDB's Replicaset name. + +The `uid_key` is only used if the sample data is being loaded. It should match the key of a production server and should be kept private. + +The default `data.json` file creates six users with user names `alice`, `bob`, `carol`, `dave`, `frank`, and `tino` (chat bot user). Passwords are the same as the user names with 123 appended, e.g. user `alice` gets password `alice123`; `tino` gets a randomly generated password. It also creates three group topics, and multiple peer to peer topics. Users are subscribed to topics and to each other. All topics are randomly filled with messages. + +Avatar photos curtesy of https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/). + +## Links: + +* [RethinkDB schema](https://github.com/tinode/chat/tree/master/server/db/rethinkdb/schema.md) +* [MySQL schema](https://github.com/tinode/chat/tree/master/server/db/mysql/schema.sql) +* [MongoDB schema](https://github.com/tinode/chat/tree/master/server/db/mongodb/schema.md) +* [PostgreSQL schema](https://github.com/tinode/chat/tree/master/server/db/postgres/schema.sql) diff --git a/tinode-db/abc-128.jpg b/tinode-db/abc-128.jpg index e69de29..56963da 100644 Binary files a/tinode-db/abc-128.jpg and b/tinode-db/abc-128.jpg differ diff --git a/tinode-db/abcdef-128.jpg b/tinode-db/abcdef-128.jpg index e69de29..2d512b9 100644 Binary files a/tinode-db/abcdef-128.jpg and b/tinode-db/abcdef-128.jpg differ diff --git a/tinode-db/alice-128.jpg b/tinode-db/alice-128.jpg index e69de29..ff10ba2 100644 Binary files a/tinode-db/alice-128.jpg and b/tinode-db/alice-128.jpg differ diff --git a/tinode-db/bf-128.jpg b/tinode-db/bf-128.jpg index e69de29..79d7bb3 100644 Binary files a/tinode-db/bf-128.jpg and b/tinode-db/bf-128.jpg differ diff --git a/tinode-db/bob-128.jpg b/tinode-db/bob-128.jpg index e69de29..02df37f 100644 Binary files a/tinode-db/bob-128.jpg and b/tinode-db/bob-128.jpg differ diff --git a/tinode-db/carol-128.jpg b/tinode-db/carol-128.jpg index e69de29..adffbe9 100644 Binary files a/tinode-db/carol-128.jpg and b/tinode-db/carol-128.jpg differ diff --git a/tinode-db/chan-128.jpg b/tinode-db/chan-128.jpg index e69de29..49e7bee 100644 Binary files a/tinode-db/chan-128.jpg and b/tinode-db/chan-128.jpg differ diff --git a/tinode-db/credentials.sh b/tinode-db/credentials.sh old mode 100644 new mode 100755 index e69de29..730ae74 --- a/tinode-db/credentials.sh +++ b/tinode-db/credentials.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Credential extractor. Tino the Chatbot is created with a random password. The password is written +# to stdout by tinode-db. The script converts it to chatbot's authentication cookie. + +# The script takes a string like 'usr;tino;usrImlot_X9vAc;cOuTvzVa' (ignored;login;user_id;password) +# and formats it into a json chatbot's authentication cookie like +# '{"schema": "basic", "secret": "username:password", "user": "user_id"}'. + +COOKIE_FILE=$@ + +while read line; do + IFS=';' read -r -a parts <<< "$line" + if [ ${#parts[@]} -eq 0 ] ; then + continue + fi + + # If the name of the cookie file is given, write to file + # Otherwise write to stdout + if [ "$COOKIE_FILE" ]; then + exec 3>"$COOKIE_FILE" + else + exec 3>&1 + fi + + echo "{\"schema\": \"basic\", \"secret\": \"${parts[1]}:${parts[3]}\", \"user\": \"${parts[2]}\"}" 1>&3 + break +done < /dev/stdin diff --git a/tinode-db/data.json b/tinode-db/data.json index e69de29..ff65222 100644 --- a/tinode-db/data.json +++ b/tinode-db/data.json @@ -0,0 +1,385 @@ +{ +"users": [ + { + "createdAt": "-140h", + "email": "alice@example.com", + "tel": "+17025550001", + "passhash": "alice123", + "private": {"comment": "some comment 123"}, + "public": {"fn": "Alice Hatter", "photo": "alice-128.jpg", "type": "jpg"}, + "trusted": {"verified": true}, + "tags": ["Alice"], + "state": "ok", + "status": { + "text": "DND" + }, + "username": "alice", + "addressBook": ["email:bob@example.com", "email:carol@example.com", "email:dave@example.com", + "email:eve@example.com","email:frank@example.com","email:george@example.com","email:tino@example.com", + "tel:+17025550001", "tel:+17025550002", "tel:+17025550003", "tel:+17025550004", "tel:+17025550005", + "tel:+17025550006", "tel:+17025550007", "tel:+17025550008", "tel:+17025550009"] + }, + { + "createdAt": "-139h", + "email": "bob@example.com", + "tel": "+17025550002", + "passhash": "bob123", + "private": {"comment": "no comments :)"}, + "public": {"fn": "Bob Smith", "photo": "bob-128.jpg", "type": "jpg"}, + "state": "ok", + "status": "stuff", + "username": "bob", + "addressBook": ["email:alice@example.com", "email:carol@example.com", "email:dave@example.com", + "email:eve@example.com", "email:frank@example.com", "email:george@example.com", "email:tino@example.com", + "tel:+17025550001", "tel:+17025550002", "tel:+17025550003", "tel:+17025550004", "tel:+17025550005", + "tel:+17025550006", "tel:+17025550007", "tel:+17025550008", "tel:+17025550009"] + }, + { + "createdAt": "-138h", + "email": "carol@example.com", + "tel": "+17025550003", + "passhash": "carol123", + "private": {"comment": "more stuff"}, + "public": {"fn": "Carol Xmas", "photo": "carol-128.jpg", "type": "jpg"}, + "state": "", + "status": "ho ho ho", + "username": "carol", + "addressBook": ["email:alice@example.com", "email:bob@example.com", "email:dave@example.com", + "email:eve@example.com", "email:frank@example.com", "email:george@example.com", "email:tino@example.com", + "tel:+17025550001", "tel:+17025550002", "tel:+17025550003", "tel:+17025550004", "tel:+17025550005", + "tel:+17025550006", "tel:+17025550007", "tel:+17025550008", "tel:+17025550009"] + }, + { + "createdAt": "-137h", + "email": "dave@example.com", + "tel": "+17025550004", + "passhash": "dave123", + "private": {"comment": "stuff 123"}, + "public": {"fn": "Dave Goliathsson", "photo": "dave-128.jpg", "type": "jpg"}, + "state": "ok", + "status": "hiding!", + "username": "dave", + "addressBook": ["email:alice@example.com", "email:bob@example.com", "email:carol@example.com", + "email:eve@example.com", "email:frank@example.com", "email:george@example.com", "email:tino@example.com", + "tel:+17025550001", "tel:+17025550002", "tel:+17025550003", "tel:+17025550004", "tel:+17025550005", + "tel:+17025550006", "tel:+17025550007", "tel:+17025550008", "tel:+17025550009"] + }, + { + "createdAt": "-136h", + "email": "eve@example.com", + "tel": "+17025550005", + "passhash": "eve123", + "private": {"comment": "apples?"}, + "public": {"fn": "Eve Adams", "photo": "eve-128.jpg", "type": "jpg"}, + "state": "ok", + "username": "eve", + "addressBook": ["email:alice@example.com", "email:bob@example.com", "email:carol@example.com", + "email:dave@example.com", "email:george@example.com", "email:tino@example.com", + "tel:+17025550001", "tel:+17025550002", "tel:+17025550003", "tel:+17025550004", "tel:+17025550005", + "tel:+17025550006", "tel:+17025550007", "tel:+17025550008", "tel:+17025550009"] + }, + { + "createdAt": "-135h", + "email": "frank@example.com", + "tel": "+17025550006", + "passhash": "frank123", + "private": {"comment": "things, not stuff"}, + "public": {"fn": "Frank Singer"}, + "state": "ok", + "status": "singing!", + "username": "frank", + "addressBook": ["email:bob@example.com", "email:carol@example.com", "email:dave@example.com", + "email:eve@example.com", "email:george@example.com", "email:tino@example.com", + "tel:+17025550001", "tel:+17025550002", "tel:+17025550003", "tel:+17025550004", "tel:+17025550005", + "tel:+17025550007", "tel:+17025550008", "tel:+17025550009"] + }, + { + "createdAt": "-134h", + "email": "tino@example.com", + "tel": "+17025550010", + "passhash": "(random)", + "private": {"comment": "I'm a chatbot short and stout"}, + "trusted": {"staff": true}, + "public": {"fn": "Tino the Chatbot", "photo": "tino-128.jpg", "type": "jpg"}, + "state": "", + "status": "Chatting nonsensically", + "username": "tino", + "addressBook": [] + }, + { + "createdAt": "-133h", + "email": "xena@example.com", + "tel": "+17025550099", + "passhash": "xena123", + "authLevel": "root", + "private": {"comment": "No one knowns that Xena exists"}, + "public": {"fn": "Xena Pacifist Peasant", "photo": "xena-128.jpg", "type": "jpg"}, + "trusted": {"staff": true}, + "state": "", + "status": "Just a simple peaceful agriculture specialist", + "username": "xena", + "addressBook": [] + } + ], + + "grouptopics": [ + { + "createdAt": "-128h", + "name": "*ABC", + "owner": "carol", + "tags": ["flower", "flowers", "flora"], + "public": {"fn": "Let's talk about flowers", "photo": "abc-128.jpg", "type": "jpg"} + }, + { + "createdAt": "-126h", + "name": "*ABCDEF", + "owner": "alice", + "tags": ["travel"], + "public": {"fn": "Travel, travel, travel", "photo": "abcdef-128.jpg", "type": "jpg"} + }, + { + "createdAt": "-124h", + "name": "*BF", + "owner": "frank", + "tags": ["sikrit", "secret"], + "public": {"fn": "Sikrit group!", "photo": "bf-128.jpg", "type": "jpg"} + }, + { + "createdAt": "-123h", + "name": "*X", + "owner": "xena", + "tags": ["support","public"], + "public": {"fn": "Support", "photo": "support-128.jpg", "type": "jpg"}, + "trusted": {"staff": true}, + "access": {"auth": "JRWP", "anon": "JW"} + }, + { + "createdAt": "-122h", + "name": "*BACDF", + "owner": "bob", + "channel": true, + "tags": ["coffee","channel"], + "public": {"fn": "Coffee Channel", "photo": "chan-128.jpg", "type": "jpg"}, + "trusted": {"verified": true}, + "access": {"auth": "RWPD", "anon": "N"} + } + ], + "p2psubs": [ + { + "createdAt": "-120h", + "users": [{"name": "alice"}, {"name": "bob", "private": {"comment": "Alice Jo"}}] + }, + { + "createdAt": "-119h", + "users": [{"name": "alice"}, {"name": "carol", "private": {"comment": "Alice Joha"}}] + }, + { + "createdAt": "-118h", + "users": [{"name": "alice"}, {"name": "dave", "private": {"comment": "Alice not in Wunderland"}}] + }, + { + "createdAt": "-117h", + "private": {"comment": "apples to oranges"}, + "users": [{"name": "alice", "private": {"comment": "Apples"}}, + {"name": "eve", "private": {"comment": "Alice is not what she seems"}}] + }, + { + "createdAt": "-116h", + "users": [{"name": "alice", "private": {"comment": "Frank Frank Frank a-\u003ef"}}, + {"name": "frank", "private": {"comment": "Johnson f-\u003ea"}}] + }, + { + "createdAt": "-115.5h", + "users": [{"name": "alice", "private": {"comment": "Python chatbot, short and stout"}}, {"name": "tino"}] + }, + { + "createdAt": "-114h", + "users": [{"name": "bob", "private": {"comment": "I'm banned by Dave!"}, "have": "A"}, + {"name": "dave", "private": {"comment": "I banned Bob."}, "want": "N"}] + }, + { + "createdAt": "-113.5h", + "users": [{"name": "bob", "private": {"comment": "Eve nee Ng"}}, {"name": "eve"}] + }, + { + "createdAt": "-113.45h", + "users": [{"name": "bob", "private": {"comment": "Python chatbot, short and stout"}}, {"name": "tino"}] + }, + { + "createdAt": "-113.3h", + "users": [{"name": "carol", "private": {"comment": "Python chatbot, short and stout"}}, {"name": "tino"}] + }, + { + "createdAt": "-112.7h", + "private": {"comment": "Python chatbot short and stout"}, + "users": [{"name": "dave", "private": {"comment": "Python chatbot, short and stout"}}, {"name": "tino"}] + }, + { + "createdAt": "-112.3h", + "users": [{"name": "eve", "private": {"comment": "Python chatbot, short and stout"}}, {"name": "tino"}] + }, + { + "createdAt": "-112.1h", + "users": [{"name": "frank", "private": {"comment": "Python chatbot, short and stout"}}, {"name": "tino"}] + } + ], + "groupsubs": [{ + "createdAt": "-112h", + "private": {"comment": "My super cool group topic"}, + "topic": "*ABC", + "user": "alice" + }, + { + "createdAt": "-111.9h", + "private": {"comment": "Wow"}, + "topic": "*ABC", + "user": "bob" + }, + { + "createdAt": "-111.8h", + "private": {"comment": "Custom group description by Bob"}, + "topic": "*ABCDEF", + "user": "bob" + }, + { + "createdAt": "-111.7h", + "private": {"comment": "Kirgudu"}, + "topic": "*ABCDEF", + "user": "carol" + }, + { + "createdAt": "-111.6h", + "topic": "*ABCDEF", + "user": "dave" + }, + { + "createdAt": "-111.5h", + "topic": "*ABCDEF", + "user": "eve" + }, + { + "createdAt": "-111.4h", + "topic": "*ABCDEF", + "user": "frank" + }, + { + "createdAt": "-111.3h", + "private": {"comment": "I'm not the owner, Frank is"}, + "topic": "*BF", + "user": "bob" + }, + { + "createdAt": "-111.2h", + "topic": "*BACDF", + "user": "alice" + }, + { + "createdAt": "-111.1h", + "topic": "*BACDF", + "asChan": true, + "user": "carol" + }, + { + "createdAt": "-111.0h", + "topic": "*BACDF", + "asChan": true, + "user": "dave" + }, + { + "createdAt": "-110.8h", + "topic": "*BACDF", + "asChan": true, + "user": "frank" + } + ], + "messages": [ + "Caution: Do not view laser light with remaining eye.", + "Caution: breathing may be hazardous to your health.", + "Celebrate Hannibal Day this year. Take an elephant to lunch.", + "Celibacy is not hereditary.", + "Center 1127 -- It's not just a job, it's an adventure!", + "Center meeting at 4pm in 2C-543", + "Centran manuals are available in 2B-515.", + "Charlie don't surf.", + "Children are hereditary: if your parents didn't have any, neither will you.", + "Clothes make the man. Naked people have little or no influence on society.", + "Club sandwiches, not baby seals.", + "Cocaine is nature's way of saying you make too much money.", + "Cogito Ergo Spud.", + "Cogito cogito ergo cogito sum.", + "Colorless green ideas sleep furiously.", + "Communication is only possible between equals.", + "Computers are not intelligent. They only think they are.", + "Consistency is always easier to defend than correctness.", + "Constants aren't. Variables don't. LISP does. Functions won't. Bytes do.", + "Contains no kung fu, car chases or decapitations.", + "Continental Life. Why do you ask?", + "Convictions cause convicts -- what you believe imprisons you.", + "Core Error - Bus Dumped", + "Could not open 2147478952 framebuffers.", + "Courage is something you can never totally own or totally lose.", + "Cowards die many times before their deaths;/The valiant never taste of death but once.", + "Crazee Edeee, his prices are INSANE!!!", + "Creativity is no substitute for knowing what you are doing.", + "Creditors have much better memories than debtors.", + "Critics are like eunuchs in a harem: they know how it's done, they've seen it done", + "every day, but they're unable to do it themselves. -Brendan Behan", + "Cthulhu Saves! ... in case He's hungry later.", + "Dames is like streetcars -- The oceans is full of 'em. -Archie Bunker", + "Dames lie about anything - just for practice. -Raymond Chandler", + "Damn it, i gotta get outta here!", + "Dangerous knowledge is a little thing.", + "It is certain", + "It is decidedly so", + "Without a doubt", + "Yes definitely", + "You may rely on it", + "As I see it yes", + "Most likely", + "Outlook good", + "Yes", + "No", + "No! No, no, no! No!!", + "Signs point to yes", + "Reply hazy try again", + "Ask again later", + "Better not tell you now", + "Cannot predict now", + "Concentrate and ask again", + "Don't count on it", + "My reply is no", + "My sources say no.", + "Outlook not so good", + "Very doubtful...", + "All your base are belong to us." + ], +"forms": [ + { + "txt": "Post responseYesДа", + "fmt": [ + {"at": 0, "len": 18, "tp": "FM"}, + {"at": 0, "len": 13, "tp": "ST"}, + {"at": 13, "len": 3, "key": 0}, + {"at": 16, "len": 2, "key": 1} + ], + "ent": [ + {"tp": "BN", "data": {"name": "yes", "act": "pub"}}, + {"tp": "BN", "data": {"name": "duh", "val": "42", "act": "pub"}} + ] + }, + { + "txt": "Go to URL:Yes OK", + "fmt": [ + {"at": 0, "len": 16, "tp": "FM"}, + {"at": 0, "len": 10, "tp": "ST"}, + {"at": 10, "len": 6, "tp": "RW"}, + {"at": 10, "len": 3, "key": 0}, + {"at": 14, "len": 2, "key": 1} + ], + "ent": [ + {"tp": "BN", "data": {"name": "ok", "act": "url", "ref": "https://github.com/tinode/chat/?key=val"}}, + {"tp": "BN", "data": {"name": "oops", "val": "test", "act": "url", "ref": "https://github.com/tinode/chat"}} + ] + } +] +} diff --git a/tinode-db/dave-128.jpg b/tinode-db/dave-128.jpg index e69de29..dcf78b6 100644 Binary files a/tinode-db/dave-128.jpg and b/tinode-db/dave-128.jpg differ diff --git a/tinode-db/eve-128.jpg b/tinode-db/eve-128.jpg index e69de29..472818c 100644 Binary files a/tinode-db/eve-128.jpg and b/tinode-db/eve-128.jpg differ diff --git a/tinode-db/gendb.go b/tinode-db/gendb.go index e69de29..2409095 100644 --- a/tinode-db/gendb.go +++ b/tinode-db/gendb.go @@ -0,0 +1,465 @@ +package main + +import ( + "fmt" + "log" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tinode/chat/server/auth" + _ "github.com/tinode/chat/server/auth/basic" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +func genDb(data *Data, p2pDel bool, tenantID types.TenantID) { + var err error + var botAccount string + + if len(data.Users) == 0 { + log.Println("No data provided, stopping") + + return + } + + // Add authentication record + authHandler := store.Store.GetAuthHandler("basic") + authHandler.Init([]byte(`{"add_to_tags": true}`), "basic") + + nameIndex := make(map[string]string, len(data.Users)) + + log.Println("Generating users...") + + for _, uu := range data.Users { + state, err := types.NewObjState(uu.State) + if err != nil { + log.Fatal(err) + } + user := types.User{ + TenantID: tenantID, + State: state, + Access: types.DefaultAccess{ + Auth: types.ModeCAuth, + Anon: types.ModeNone, + }, + Tags: uu.Tags, + Public: parsePublic(&uu.Public, data.datapath), + } + if !uu.Trusted.IsZero() { + user.Trusted = uu.Trusted + } + user.CreatedAt = getCreatedTime(uu.CreatedAt) + + user.Tags = append(user.Tags, "basic:"+uu.Username) + if uu.Email != "" { + user.Tags = append(user.Tags, "email:"+uu.Email) + } + if uu.Tel != "" { + user.Tags = append(user.Tags, "tel:"+uu.Tel) + } + + // store.Users.Create will subscribe user to !me topic but won't create a !me topic + if _, err := store.Users.Create(tenantID, &user, uu.Private); err != nil { + log.Fatal(err) + } + + // Save credentials: email and phone number as if they were confirmed. + if uu.Email != "" { + if _, err := store.Users.UpsertCred(tenantID, &types.Credential{ + User: user.Id, + Method: "email", + Value: uu.Email, + Done: true, + }); err != nil { + log.Fatal(err) + } + } + if uu.Tel != "" { + if _, err := store.Users.UpsertCred(tenantID, &types.Credential{ + User: user.Id, + Method: "tel", + Value: uu.Tel, + Done: true, + }); err != nil { + log.Fatal(err) + } + } + + authLevel := auth.LevelAuth + if uu.AuthLevel != "" { + authLevel = auth.ParseAuthLevel(uu.AuthLevel) + if authLevel == auth.LevelNone { + log.Fatal("Unknown authLevel", uu.AuthLevel) + } + } + // Add authentication record + authHandler := store.Store.GetAuthHandler("basic") + passwd := uu.Password + if passwd == "(random)" { + // Generate random password + passwd = getPassword(8) + botAccount = uu.Username + } + if _, err := authHandler.AddRecord(auth.AuthContext{TenantID: tenantID}, + &auth.Rec{TenantID: tenantID, Uid: user.Uid(), AuthLevel: authLevel}, + []byte(uu.Username+":"+passwd)); err != nil { + log.Fatal(err) + } + nameIndex[uu.Username] = user.Id + + // Add address book as fnd.private + if len(uu.AddressBook) > 0 { + if err := store.Subs.Update(tenantID, user.Uid().FndName(), user.Uid(), + map[string]any{"Private": strings.Join(uu.AddressBook, ",")}); err != nil { + log.Fatal(err) + } + } + + fmt.Println("usr;" + uu.Username + ";" + user.Uid().UserId() + ";" + passwd) + } + + if botAccount == "" && len(data.Users) > 0 { + botAccount = data.Users[0].Username + } + + log.Println("Generating group topics...") + + for _, gt := range data.Grouptopics { + name := genTopicName() + nameIndex[gt.Name] = name + + accessAuth := types.ModeCPublic + if gt.Access.Auth != "" { + if err := accessAuth.UnmarshalText([]byte(gt.Access.Auth)); err != nil { + log.Fatal("Invalid Auth access mode", gt.Access.Auth, err) + } + } + accessAnon := types.ModeCReadOnly + if gt.Access.Anon != "" { + if err := accessAnon.UnmarshalText([]byte(gt.Access.Anon)); err != nil { + log.Fatal("Invalid Anon access mode", gt.Access.Anon, err) + } + } + topic := &types.Topic{ + ObjHeader: types.ObjHeader{Id: name}, + TenantID: tenantID, + Access: types.DefaultAccess{ + Auth: accessAuth, + Anon: accessAnon, + }, + UseBt: gt.Channel, + Tags: gt.Tags, + Public: parsePublic(>.Public, data.datapath), + } + if !gt.Trusted.IsZero() { + topic.Trusted = gt.Trusted + } + var owner types.Uid + if gt.Owner != "" { + owner = types.ParseUid(nameIndex[gt.Owner]) + if owner.IsZero() { + log.Fatal("Invalid owner", gt.Owner, "for topic", gt.Name) + } + topic.GiveAccess(owner, types.ModeCFull, types.ModeCFull) + } + topic.CreatedAt = getCreatedTime(gt.CreatedAt) + + if err = store.Topics.Create(tenantID, topic, owner, gt.OwnerPrivate); err != nil { + log.Fatal(err) + } + fmt.Println("grp;" + gt.Name + ";" + name) + } + + log.Println("Generating P2P subscriptions...") + + for i, ss := range data.P2psubs { + if ss.Users[0].Name < ss.Users[1].Name { + ss.pair = ss.Users[0].Name + ":" + ss.Users[1].Name + } else { + ss.pair = ss.Users[1].Name + ":" + ss.Users[0].Name + } + + uid1 := types.ParseUid(nameIndex[ss.Users[0].Name]) + uid2 := types.ParseUid(nameIndex[ss.Users[1].Name]) + topic := uid1.P2PName(uid2) + created := getCreatedTime(ss.CreatedAt) + + // Assign default access mode + defaultMode := types.ModeCP2P + if p2pDel { + defaultMode = types.ModeCP2PD + } + s0want := defaultMode + s0given := defaultMode + s1want := defaultMode + s1given := defaultMode + + // Check of non-default access mode was provided + if ss.Users[0].Want != "" { + if err := s0want.UnmarshalText([]byte(ss.Users[0].Want)); err != nil { + log.Fatal(err) + } + } + if ss.Users[0].Have != "" { + if err := s0given.UnmarshalText([]byte(ss.Users[0].Have)); err != nil { + log.Fatal(err) + } + } + if ss.Users[1].Want != "" { + if err := s1want.UnmarshalText([]byte(ss.Users[1].Want)); err != nil { + log.Fatal(err) + } + } + if ss.Users[1].Have != "" { + if err := s1given.UnmarshalText([]byte(ss.Users[1].Have)); err != nil { + log.Fatal(err) + } + } + + err := store.Topics.CreateP2P(tenantID, + &types.Subscription{ + ObjHeader: types.ObjHeader{CreatedAt: created}, + TenantID: tenantID, + User: uid1.String(), + Topic: topic, + ModeWant: s0want, + ModeGiven: s0given, + Private: ss.Users[0].Private}, + &types.Subscription{ + ObjHeader: types.ObjHeader{CreatedAt: created}, + TenantID: tenantID, + User: uid2.String(), + Topic: topic, + ModeWant: s1want, + ModeGiven: s1given, + Private: ss.Users[1].Private}) + + if err != nil { + log.Fatal(err) + } + + data.P2psubs[i].pair = ss.pair + nameIndex[ss.pair] = topic + fmt.Println("p2p;" + ss.pair + ";" + topic) + } + + log.Println("Generating group subscriptions...") + + for _, ss := range data.Groupsubs { + var want, given types.AccessMode + if ss.AsChan { + want = types.ModeCChnReader + given = types.ModeCChnReader + } else { + want = types.ModeCPublic + given = types.ModeCPublic + } + if ss.Want != "" { + if err := want.UnmarshalText([]byte(ss.Want)); err != nil { + log.Fatal(err) + } + } + if ss.Have != "" { + if err := given.UnmarshalText([]byte(ss.Have)); err != nil { + log.Fatal(err) + } + } + tname := nameIndex[ss.Topic] + if ss.AsChan { + tname = types.GrpToChn(tname) + } + if err = store.Subs.Create(tenantID, &types.Subscription{ + ObjHeader: types.ObjHeader{CreatedAt: getCreatedTime(ss.CreatedAt)}, + TenantID: tenantID, + User: nameIndex[ss.User], + Topic: tname, + ModeWant: want, + ModeGiven: given, + Private: ss.Private}); err != nil { + + log.Fatal(err) + } + } + + seqIds := map[string]int{} + now := types.TimeNow().Add(-time.Minute * 10) + + messageCount := len(data.Messages) + if messageCount > 0 { + log.Println("Inserting messages...") + + if messageCount > 1 { + // Shuffle messages + rand.Shuffle(len(data.Messages), func(i, j int) { + data.Messages[i], data.Messages[j] = data.Messages[j], data.Messages[i] + }) + + // Starting 4 days ago. + timestamp := now.Add(time.Hour * time.Duration(-24*4)) + toInsert := 96 // 96 is the maximum, otherwise messages may appear in the future + // Initial maximum increment of the message sent time in milliseconds + increment := 3600 * 1000 + subIdx := rand.Intn(len(data.Groupsubs) + len(data.P2psubs)*2) + for i := range toInsert { + // At least 20% of subsequent messages should come from the same user in the same topic. + if rand.Intn(5) > 0 { + subIdx = rand.Intn(len(data.Groupsubs) + len(data.P2psubs)*2) + } + + var topic string + var from types.Uid + if subIdx < len(data.Groupsubs) { + if data.Groupsubs[subIdx].AsChan { + // Channel readers should not have any published messages. + continue + } + topic = nameIndex[data.Groupsubs[subIdx].Topic] + from = types.ParseUid(nameIndex[data.Groupsubs[subIdx].User]) + } else { + idx := (subIdx - len(data.Groupsubs)) / 2 + usr := (subIdx - len(data.Groupsubs)) % 2 + sub := data.P2psubs[idx] + topic = nameIndex[sub.pair] + from = types.ParseUid(nameIndex[sub.Users[usr].Name]) + } + + seqIds[topic]++ + seqId := seqIds[topic] + str := data.Messages[i%len(data.Messages)] + // Max time between messages is 2 hours, averate - 1 hour, time is increasing as seqId increases + timestamp = timestamp.Add(time.Microsecond * time.Duration(rand.Intn(increment))) + if timestamp.After(now) { + now = timestamp + } + if err, _ = store.Messages.Save(tenantID, &types.Message{ + ObjHeader: types.ObjHeader{CreatedAt: timestamp}, + TenantID: tenantID, + SeqId: seqId, + Topic: topic, + From: from.String(), + Content: str, + }, nil, true); err != nil { + log.Fatal("Failed to insert message: ", err) + } + + // New increment: remaining time until 'now' divided by the number of messages to be inserted, + // then converted to milliseconds. + increment = int(now.Sub(timestamp).Nanoseconds() / int64(toInsert-i) / 1000000) + + // log.Printf("Msg.seq=%d at %v, topic='%s' from='%s'", msg.SeqId, msg.CreatedAt, topic, from.UserId()) + } + } else { + // Only one message is provided. Just insert it into every topic. + now := time.Now().UTC().Add(-time.Minute).Round(time.Millisecond) + + for _, gt := range data.Grouptopics { + seqIds[nameIndex[gt.Name]] = 1 + if err, _ = store.Messages.Save(tenantID, &types.Message{ + ObjHeader: types.ObjHeader{CreatedAt: now}, + TenantID: tenantID, + SeqId: 1, + Topic: nameIndex[gt.Name], + From: nameIndex[gt.Owner], + Content: data.Messages[0], + }, nil, true); err != nil { + log.Fatal("Failed to insert message: ", err) + } + } + + usedp2p := make(map[string]bool) + for i := 0; len(usedp2p) < len(data.P2psubs)/2; i++ { + sub := data.P2psubs[i] + if usedp2p[nameIndex[sub.pair]] { + continue + } + usedp2p[nameIndex[sub.pair]] = true + seqIds[nameIndex[sub.pair]] = 1 + if err, _ = store.Messages.Save(tenantID, &types.Message{ + ObjHeader: types.ObjHeader{CreatedAt: now}, + TenantID: tenantID, + SeqId: 1, + Topic: nameIndex[sub.pair], + From: nameIndex[sub.Users[0].Name], + Content: data.Messages[0], + }, nil, true); err != nil { + log.Fatal("Failed to insert message: ", err) + } + } + } + } + + if len(data.Forms) != 0 { + from := nameIndex[botAccount] + log.Println("Inserting forms as ", botAccount, from) + ts := now + for _, form := range data.Forms { + for _, sub := range data.P2psubs { + ts = ts.Add(time.Second) + seqIds[nameIndex[sub.pair]]++ + seqId := seqIds[nameIndex[sub.pair]] + if sub.Users[0].Name == botAccount || sub.Users[1].Name == botAccount { + if err, _ = store.Messages.Save(tenantID, &types.Message{ + ObjHeader: types.ObjHeader{CreatedAt: ts}, + TenantID: tenantID, + SeqId: seqId, + Topic: nameIndex[sub.pair], + Head: types.KVMap{"mime": "text/x-drafty"}, + From: from, + Content: form, + }, nil, true); err != nil { + log.Fatal("Failed to insert form: ", err) + } + } + } + } + } + + log.Println("Sample data processing completed.") +} + +// Go json cannot unmarshal Duration from a string, thus this hack. +func getCreatedTime(delta string) time.Time { + dd, err := time.ParseDuration(delta) + if err != nil && delta != "" { + log.Fatal("Invalid duration string", delta) + } + + return time.Now().UTC().Round(time.Millisecond).Add(dd) +} + +type photoStruct struct { + Type string `json:"type" db:"type"` + Data []byte `json:"data" db:"data"` +} + +type card struct { + Fn string `json:"fn" db:"fn"` + Photo *photoStruct `json:"photo,omitempty" db:"photo"` +} + +// {"fn": "Alice Johnson", "photo": "alice-128.jpg"} +func parsePublic(public *theCard, path string) *card { + var photo *photoStruct + var err error + + if public.Fn == "" && public.Photo == "" { + return nil + } + + if fname := public.Photo; fname != "" { + photo = &photoStruct{Type: public.Type} + dir, _ := filepath.Split(fname) + if dir == "" { + dir = path + } + photo.Data, err = os.ReadFile(filepath.Join(dir, fname)) + if err != nil { + log.Fatal(err) + } + } + + return &card{Fn: public.Fn, Photo: photo} +} diff --git a/tinode-db/generate_dataset.py b/tinode-db/generate_dataset.py index e69de29..fb73697 100644 --- a/tinode-db/generate_dataset.py +++ b/tinode-db/generate_dataset.py @@ -0,0 +1,104 @@ +# Script that generates a synthetic Tinode dataset in the same format as data.json. +# Run as: +# $ python generate_dataset.py --num_users X +# +# User ids and passwords are: userN and userN123 where N is in [0..X). +# The output will be dumped to stdout. + +import argparse as ap +import json +import numpy as np +import random +import sys + +parser = ap.ArgumentParser(description='Simple python script to generate synthetic Tinode datasets.') +parser.add_argument('--num_users', type=int, default=50, help='Number of user accounts (default: 50).') +args = parser.parse_args() + +random.seed() +np.random.seed() + +data = dict() + +# Users. +num_users = args.num_users +users = list() +ul = ['user%d' % i for i in range(num_users)] + +for i in range(num_users): + userid = ul[i] + u = { + 'createdAt': '-%dh' % random.randint(1, 300), + 'email': '%s@example.com' % userid, + 'passhash': '%s123' % userid, + 'private': {'comment': 'some comment 123'}, + 'public': {'fn': userid}, + 'tags': [userid], + 'state': 'ok', + 'status': { + 'text': 'my status %s' % userid + }, + 'username': userid, + } + users.append(u) + +data['users'] = users + +# Groups. +group_topics = list() +# TODO: make it configurable. +num_groups = random.randint(1, num_users >> 1) +for i in range(num_groups): + owner = random.choice(ul) + g = { + 'createdAt': '-%dh' % random.randint(1, 300), + 'name': '*ABCgroup%d' % i, + 'owner': owner, + 'tags': ['group%d' % i], + 'public': {'fn': 'My group %d' % i} + } + group_topics.append(g) + +data['grouptopics'] = group_topics + +# P2P subs. +p2p_subs = list() +ids = set() + +# TODO: Poisson mean should be configurable. +num_contacts = np.random.poisson(40, num_users).tolist() +num_contacts = [min(max(2, int(s)), num_users - 1) for s in num_contacts] + +all_ids = [i for i in range(num_users)] + +for i in range(num_users): + contacts = [ul[x] for x in random.sample(all_ids, num_contacts[i]) if x > i] + for c in contacts: + p2p = { + 'createdAt': '-%dh' % random.randint(1, 300), + 'users': [{'name': ul[i]}, {'name': c}] + } + p2p_subs.append(p2p) + +data['p2psubs'] = p2p_subs + +# Group subs. +# TODO: Poisson mean should be configurable as well. +sizes = np.random.poisson(4, num_groups).tolist() +sizes = [min(max(2, int(s)), num_users) for s in sizes] +group_subs = list() + +for i in range(num_groups): + k = sizes[i] + gs = random.sample(ul, k) + for uid in gs: + g = { + 'createdAt': '-%dh' % random.randint(1, 300), + 'topic': '*ABCgroup%d' % i, + 'user': uid + } + group_subs.append(g) + +data['groupsubs'] = group_subs + +json.dump(data, sys.stdout, ensure_ascii=False, indent=4) diff --git a/tinode-db/main.go b/tinode-db/main.go index e69de29..e809c69 100644 --- a/tinode-db/main.go +++ b/tinode-db/main.go @@ -0,0 +1,382 @@ +package main + +import ( + crand "crypto/rand" + "encoding/json" + "flag" + "log" + "math/rand" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/tinode/chat/server/auth" + _ "github.com/tinode/chat/server/db/mongodb" + _ "github.com/tinode/chat/server/db/mysql" + _ "github.com/tinode/chat/server/db/postgres" + _ "github.com/tinode/chat/server/db/rethinkdb" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" + jcr "github.com/tinode/jsonco" +) + +type configType struct { + P2PDeleteEnabled bool `json:"p2p_delete_enabled"` + StoreConfig json.RawMessage `json:"store_config"` +} + +type theCard struct { + Fn string `json:"fn"` + Photo string `json:"photo"` + Type string `json:"type"` +} + +type tPrivate struct { + Comment string `json:"comment"` +} + +type tTrusted struct { + Verified bool `json:"verified,omitempty"` + Staff bool `json:"staff,omitempty"` +} + +func (t tTrusted) IsZero() bool { + return !t.Verified && !t.Staff +} + +// DefAccess is default access mode. +type DefAccess struct { + Auth string `json:"auth"` + Anon string `json:"anon"` +} + +/* +User object in data.json + + "createdAt": "-140h", + "email": "alice@example.com", + "tel": "17025550001", + "passhash": "alice123", + "private": {"comment": "some comment 123"}, + "public": {"fn": "Alice Johnson", "photo": "alice-64.jpg", "type": "jpg"}, + "state": "ok", + "authLevel": "auth", + "status": { + "text": "DND" + }, + "username": "alice", + "tags": ["tag1"], + "addressBook": ["email:bob@example.com", "email:carol@example.com", "email:dave@example.com", + "email:eve@example.com","email:frank@example.com","email:george@example.com","email:tob@example.com", + "tel:17025550001", "tel:17025550002", "tel:17025550003", "tel:17025550004", "tel:17025550005", + "tel:17025550006", "tel:17025550007", "tel:17025550008", "tel:17025550009"] + } +*/ +type User struct { + CreatedAt string `json:"createdAt"` + Email string `json:"email"` + Tel string `json:"tel"` + AuthLevel string `json:"authLevel"` + Username string `json:"username"` + Password string `json:"passhash"` + Private tPrivate `json:"private"` + Public theCard `json:"public"` + Trusted tTrusted `json:"trusted"` + State string `json:"state"` + Status any `json:"status"` + AddressBook []string `json:"addressBook"` + Tags []string `json:"tags"` +} + +/* +GroupTopic object in data.json + + "createdAt": "-128h", + "name": "*ABC", + "owner": "carol", + "channel": true, + "public": {"fn": "Let's talk about flowers", "photo": "abc-64.jpg", "type": "jpg"} +*/ +type GroupTopic struct { + CreatedAt string `json:"createdAt"` + Name string `json:"name"` + Owner string `json:"owner"` + Channel bool `json:"channel"` + Public theCard `json:"public"` + Trusted tTrusted `json:"trusted"` + Access DefAccess `json:"access"` + Tags []string `json:"tags"` + OwnerPrivate tPrivate `json:"ownerPrivate"` +} + +/* +GroupSub object in data.json + + "createdAt": "-112h", + "private": "My super cool group topic", + "topic": "*ABC", + "user": "alice", + "asChan: false, + "want": "JRWPSA", + "have": "JRWP" +*/ +type GroupSub struct { + CreatedAt string `json:"createdAt"` + Private tPrivate `json:"private"` + Topic string `json:"topic"` + User string `json:"user"` + AsChan bool `json:"asChan"` + Want string `json:"want"` + Have string `json:"have"` +} + +/* +P2PUser topic in data.json + +"createdAt": "-117h", +"users": [ + + {"name": "eve", "private": {"comment":"ho ho"}, "want": "JRWP", "have": "N"}, + {"name": "alice", "private": {"comment": "ha ha"}} + +] +*/ +type P2PUser struct { + Name string `json:"name"` + Private tPrivate `json:"private"` + Want string `json:"want"` + Have string `json:"have"` +} + +// P2PSub is a p2p subscription in data.json +type P2PSub struct { + CreatedAt string `json:"createdAt"` + Users []P2PUser `json:"users"` + // Cached value 'user1:user2' as a surrogare topic name + pair string +} + +// Data is a message in data.json. +type Data struct { + Users []User `json:"users"` + Grouptopics []GroupTopic `json:"grouptopics"` + Groupsubs []GroupSub `json:"groupsubs"` + P2psubs []P2PSub `json:"p2psubs"` + Messages []string `json:"messages"` + Forms []map[string]any `json:"forms"` + datapath string +} + +// Generate random string as a name of the group topic +func genTopicName() string { + return "grp" + store.Store.GetUidString() +} + +// Generates password of length n +func getPassword(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/.+?=&" + + rbuf := make([]byte, n) + if _, err := crand.Read(rbuf); err != nil { + log.Fatalln("Unable to generate password", err) + } + + passwd := make([]byte, n) + for i, r := range rbuf { + passwd[i] = letters[int(r)%len(letters)] + } + + return string(passwd) +} + +func main() { + reset := flag.Bool("reset", false, "force database reset") + upgrade := flag.Bool("upgrade", false, "perform database version upgrade") + noInit := flag.Bool("no_init", false, "check that database exists but don't create if missing") + addRoot := flag.String("add_root", "", "create ROOT user, auth scheme 'basic'") + makeRoot := flag.String("make_root", "", "promote ordinary user to ROOT, auth scheme 'basic'") + datafile := flag.String("data", "", "name of file with sample data to load") + conffile := flag.String("config", "./tinode.conf", "config of the database connection") + tenantIDFlag := flag.Int64("tenant_id", 0, "tenant ID for users created or modified by this command") + + flag.Parse() + tenantID := types.TenantID(*tenantIDFlag) + + var data Data + if *datafile != "" && *datafile != "-" { + raw, err := os.ReadFile(*datafile) + if err != nil { + log.Fatalln("Failed to read sample data file:", err) + } + err = json.Unmarshal(raw, &data) + if err != nil { + log.Fatalln("Failed to parse sample data:", err) + } + } + + rand.Seed(time.Now().UnixNano()) + data.datapath, _ = filepath.Split(*datafile) + + var config configType + if file, err := os.Open(*conffile); err != nil { + log.Fatalln("Failed to read config file:", err) + } else { + jr := jcr.New(file) + if err = json.NewDecoder(jr).Decode(&config); err != nil { + switch jerr := err.(type) { + case *json.UnmarshalTypeError: + lnum, cnum, _ := jr.LineAndChar(jerr.Offset) + log.Fatalf("Unmarshall error in config file in %s at %d:%d (offset %d bytes): %s", + jerr.Field, lnum, cnum, jerr.Offset, jerr.Error()) + case *json.SyntaxError: + lnum, cnum, _ := jr.LineAndChar(jerr.Offset) + log.Fatalf("Syntax error in config file at %d:%d (offset %d bytes): %s", + lnum, cnum, jerr.Offset, jerr.Error()) + default: + log.Fatal("Failed to parse config file: ", err) + } + } + } + + err := store.Store.Open(1, config.StoreConfig) + defer store.Store.Close() + + adapterVersion := store.Store.GetAdapterVersion() + databaseVersion := 0 + if store.Store.IsOpen() { + databaseVersion = store.Store.GetDbVersion() + } + log.Printf("Database adapter: '%s'; version: %d", store.Store.GetAdapterName(), adapterVersion) + + var created bool + + if err != nil { + if strings.Contains(err.Error(), "Database not initialized") { + if *noInit { + log.Fatalln("Database not found.") + } + log.Println("Database not found. Creating.") + err = store.Store.InitDb(config.StoreConfig, false) + if err == nil { + log.Println("Database successfully created.") + created = true + } + } else if strings.Contains(err.Error(), "Invalid database version") { + msg := "Wrong DB version: expected " + strconv.Itoa(adapterVersion) + ", got " + + strconv.Itoa(databaseVersion) + "." + + if *reset { + log.Println(msg, "Reset Requested. Dropping and recreating the database.") + err = store.Store.InitDb(config.StoreConfig, true) + if err == nil { + log.Println("Database successfully reset.") + } + } else if *upgrade { + if databaseVersion > adapterVersion { + log.Fatalln(msg, "Unable to upgrade: database has greater version than the adapter.") + } + log.Println(msg, "Upgrading the database.") + err = store.Store.UpgradeDb(config.StoreConfig) + if err == nil { + log.Println("Database successfully upgraded.") + } + } else { + log.Fatalln(msg, "Use --reset to reset, --upgrade to upgrade.") + } + } else { + log.Fatalln("Failed to init DB adapter:", err) + } + } else if *reset { + log.Println("Reset requested. Dropping and recreating the database.") + err = store.Store.InitDb(config.StoreConfig, true) + if err == nil { + log.Println("Database successfully reset.") + } + } else { + log.Println("Database exists, version is correct.") + } + + if err != nil { + log.Fatalln("Failure:", err) + } + + if *reset || created { + if tenantID.IsZero() && len(data.Users) > 0 { + log.Fatalln("Must specify --tenant_id when loading sample users") + } + genDb(&data, config.P2PDeleteEnabled, tenantID) + } else if len(data.Users) > 0 { + log.Println("Sample data ignored.") + } + + // Promote existing user account to root + if *makeRoot != "" { + if tenantID.IsZero() { + log.Fatalln("Must specify --tenant_id when promoting a tenant user") + } + adapter := store.Store.GetAdapter() + userId := types.ParseUserId(*makeRoot) + if userId.IsZero() { + log.Fatalf("Must specify a valid user ID '%s' to promote to ROOT", *makeRoot) + } + login, _, _, _, err := store.Users.GetAuthRecord(tenantID, userId, "basic") + if err != nil { + log.Fatalln("Failed to read user authentication record", err) + } + tenantAdapter, ok := adapter.(interface { + TenantAuthUpdRecord(types.TenantID, types.Uid, string, string, auth.Level, []byte, time.Time) error + }) + if !ok { + log.Fatalln("Configured adapter does not support tenant authentication") + } + if err = tenantAdapter.TenantAuthUpdRecord(tenantID, userId, "basic", "basic:"+login, + auth.LevelRoot, nil, time.Time{}); err != nil { + log.Fatalln("Failed to promote user to ROOT", err) + } + log.Printf("User '%s' promoted to ROOT", *makeRoot) + } + + // Create root user account. + if *addRoot != "" { + if tenantID.IsZero() { + log.Fatalln("Must specify --tenant_id when creating a tenant user") + } + var password string + parts := strings.Split(*addRoot, ":") + uname := parts[0] + if len(uname) < 3 { + log.Fatalf("Failed to create a ROOT user: username '%s' is too short", uname) + } + + if len(parts) == 1 || parts[1] == "" { + password = getPassword(10) + } else { + password = parts[1] + } + + var user types.User + user.TenantID = tenantID + user.Public = &card{ + Fn: "ROOT " + uname, + } + if _, err := store.Users.Create(tenantID, &user, nil); err != nil { + log.Fatalln("Failed to create ROOT user:", err) + } + + authHandler := store.Store.GetAuthHandler("basic") + ctx := auth.AuthContext{TenantID: tenantID} + if _, err := authHandler.AddRecord(ctx, + &auth.Rec{TenantID: tenantID, Uid: user.Uid(), AuthLevel: auth.LevelRoot}, + []byte(uname+":"+password)); err != nil { + store.Users.Delete(tenantID, user.Uid(), true) + log.Fatalln("Failed to add ROOT auth record:", err) + } + log.Printf("ROOT user created: '%s:%s'", uname, password) + } + + log.Println("All done.") + + os.Exit(0) +} diff --git a/tinode-db/support-128.jpg b/tinode-db/support-128.jpg index e69de29..b45e6bb 100644 Binary files a/tinode-db/support-128.jpg and b/tinode-db/support-128.jpg differ diff --git a/tinode-db/tino-128.jpg b/tinode-db/tino-128.jpg index e69de29..ae40945 100644 Binary files a/tinode-db/tino-128.jpg and b/tinode-db/tino-128.jpg differ diff --git a/tinode-db/tinode.conf b/tinode-db/tinode.conf index e69de29..87cd8dd 100644 --- a/tinode-db/tinode.conf +++ b/tinode-db/tinode.conf @@ -0,0 +1,29 @@ +{ + "p2p_delete_enabled": true, + "store_config": { + "uid_key": "la6YsO+bNX/+XIkOqc5Svw==", + "use_adapter": "mysql", + "adapters": { + "postgres": { + "database": "tinode", + "dsn": "postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable" + }, + "mysql": { + "database": "tinode", + "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(120.25.72.121:53306)/tinode?parseTime=true&collation=utf8mb4_unicode_ci" + }, + "rethinkdb": { + "database": "tinode", + "addresses": "localhost:28015" + }, + "mongodb": { + "database": "tinode", + "addresses": "localhost:27017", + //"replica_set": "rs0", + //"auth_source": "admin", + //"username": "tinode", + //"password": "tinode", + } + } + } +} diff --git a/tinode-db/xena-128.jpg b/tinode-db/xena-128.jpg index e69de29..6f46aff 100644 Binary files a/tinode-db/xena-128.jpg and b/tinode-db/xena-128.jpg differ diff --git a/tn-cli/CODE-STRUCTURE.md b/tn-cli/CODE-STRUCTURE.md index e69de29..d35fa59 100644 --- a/tn-cli/CODE-STRUCTURE.md +++ b/tn-cli/CODE-STRUCTURE.md @@ -0,0 +1,201 @@ +# tn-cli Refactoring Summary + +The code is organized into following focused modules: + +## New Structure + +### 1. **tn-cli.py** (Entry Point - ~120 lines) +- Command-line argument parsing +- Application initialization +- Version handling +- Authentication setup (token, basic, cookie) +- Macro loading +- Entry point that calls `run()` from client module + +**Key functions:** +- `exception_hook()` - Crash handler +- `if __name__ == '__main__'` - Main entry point + +--- + +### 2. **utils.py** (Utility Functions - ~200 lines) +- Helper functions and data structures +- File/image processing utilities +- Encoding and parsing functions + +**Key functions:** +- `dotdict` - Dictionary with dot notation access +- `makeTheCard()` - Pack user profile data +- `inline_image()` - Create drafty image messages +- `attachment()` - Create drafty attachment messages +- `encode_to_bytes()` - Convert objects to bytes +- `parse_cred()` - Parse credentials +- `parse_trusted()` - Parse trusted values + +**Constants:** +- `MAX_INBAND_ATTACHMENT_SIZE` +- `MAX_EXTERN_ATTACHMENT_SIZE` +- `MAX_IMAGE_DIM` +- `DELETE_MARKER` +- `TINODE_DEL` + +--- + +### 3. **commands.py** (Command Parsing & Message Building - ~850 lines) +- Command-line parsing for all commands +- Protobuf message construction +- Variable dereferencing +- Command serialization + +**Key functions:** +- `parse_input()` - Parse command line input +- `parse_cmd()` - Create argument parsers +- `serialize_cmd()` - Convert commands to protobuf +- `derefVals()` / `getVar()` - Variable dereferencing +- Message builders: `hiMsg()`, `accMsg()`, `loginMsg()`, `subMsg()`, `leaveMsg()`, `pubMsg()`, `getMsg()`, `setMsg()`, `delMsg()`, `noteMsg()` +- File operations: `upload()`, `fileUpload()`, `fileDownload()` +- `print_server_params()` - Log server info + +--- + +### 4. **client.py** (gRPC Client & Communication - ~260 lines) +- gRPC connection management +- Message generation and streaming +- Server response handling +- Login/authentication handling +- Cookie management + +**Key functions:** +- `run()` - Main client loop +- `gen_message()` - Generate outgoing messages +- `handle_ctrl()` - Handle server control responses +- `handle_login()` - Process login response +- `save_cookie()` / `read_cookie()` - Cookie persistence +- `pop_from_output_queue()` - Output queue management + +--- + +### 5. **input_handler.py** (User Input - ~70 lines) +- Terminal input reading +- Multi-line input support +- Interactive and non-interactive modes + +**Key functions:** +- `stdin()` - Main input loop +- `readLinesFromStdin()` - Read with prompt support + +--- + +### 6. **tn_globals.py** (Shared Global State - ~104 lines) +- Global variables shared across all modules +- Asynchronous I/O queue management +- Utility functions for logging and output +- Protobuf to JSON conversion + +**Key variables:** +- `OnCompletion` - Dictionary of callbacks for server responses +- `WaitingFor` - Outstanding synchronous command request +- `AuthToken` - Current authentication token +- `InputQueue` / `OutputQueue` - Async I/O queues +- `InputThread` - Background input thread +- `IsInteractive` - Detect if running in interactive mode +- `Prompt` - PromptSession for interactive input +- `DefaultUser` / `DefaultTopic` - Default context values +- `Variables` - Store command execution results +- `Connection` - gRPC connection to server +- `Verbose` - Extended logging flag + +**Key functions:** +- `printout()` - Print in interactive mode only +- `printerr()` - Write to stderr +- `stdout()` / `stdoutln()` - Async output to stdout +- `clip_long_string()` - Shorten long strings for logging +- `to_json()` - Convert protobuf messages to JSON + +--- + +### 7. **macros.py** (Command Macros - ~341 lines) +- High-level command macros that expand into basic commands +- Simplifies complex multi-step operations +- Requires root privileges for most operations + +**Macro base class:** +- `Macro` - Base class for all macros with parsing and execution + +**Available macros:** +- `usermod` - Modify user account (suspend/unsuspend, update theCard, trusted values) +- `resolve` - Resolve login name to user ID +- `passwd` - Set user's password +- `useradd` - Create new user account with credentials +- `chacs` - Change default permissions/acs for a user +- `userdel` - Delete user account (soft or hard delete) +- `chcred` - Add/delete/validate user credentials +- `thecard` - Print user's public/private data or credentials + +**Key functions:** +- `parse_macro()` - Find parser for macro command +- `Macro.expand()` - Expand macro to list of basic commands +- `Macro.run()` - Execute macro or explain expansion + +**Macro dictionary:** +- `Macros` - Dictionary mapping macro names to instances + +--- + +## Module Dependencies + +``` +tn-cli.py +├── tn_globals +├── client (run, read_cookie) +└── commands (set_macros_module) + +client.py +├── tn_globals +├── tinode_grpc (pb, pbx) +├── utils (dotdict) +├── input_handler (stdin) +└── commands (hiMsg, loginMsg, serialize_cmd) + +commands.py +├── tn_globals +├── tinode_grpc (pb, pbx) +├── utils (makeTheCard, inline_image, attachment, etc.) +└── client (handle_ctrl, handle_login, save_cookie) [for specific commands] + +utils.py +├── tn_globals +└── tinode_grpc (pb) + +input_handler.py +└── tn_globals + +macros.py +└── tn_globals + +tn_globals.py +└── (no dependencies - provides shared state) +``` + +## Why + +1. **Separation of Concerns**: Each module has a clear, focused responsibility +2. **Maintainability**: Easier to find and modify specific functionality +3. **Testability**: Individual modules can be tested independently +4. **Readability**: Smaller files are easier to understand +5. **Reusability**: Utilities and client code can be reused +6. **Extensibility**: Easy to add new macros or commands without modifying core logic +7. **Shared State Management**: `tn_globals.py` provides centralized state accessible to all modules + +## Usage + +Run the application using: +```bash +python3 tn-cli.py [arguments] +``` + +Or make it executable: +```bash +chmod +x tn-cli.py +./tn-cli.py [arguments] +``` diff --git a/tn-cli/LICENSE b/tn-cli/LICENSE index e69de29..eadd668 100644 --- a/tn-cli/LICENSE +++ b/tn-cli/LICENSE @@ -0,0 +1,2 @@ +Code in this folder is licensed under Apache 2.0 +http://www.apache.org/licenses/LICENSE-2.0 diff --git a/tn-cli/README.md b/tn-cli/README.md index e69de29..155f961 100644 --- a/tn-cli/README.md +++ b/tn-cli/README.md @@ -0,0 +1,107 @@ +# Command Line Client for Tinode + +This is a scriptable command line chat client. It's written in Python and can be used to extend Tinode using [gRPC](https://grpc.io) [API](../pbx/). + +Python 2.7 or 3.4+ is required. PIP 9.0.1 or newer is required. + +Install dependencies: +``` +$ python -m pip install -r requirements.txt +``` + +Run the client from the command line: +``` +python tn-cli.py --login-basic=alice:alice123 +``` + +If you are updating an existent installation, make sure the `tinode_grpc` version matches the [server](../server/) version. Upgrade `tinode_grpc` if needed: +``` +python -m pip install --upgrade tinode_grpc==X.XX.XX +``` +where `X.XX.XX` is the version number which must match the server version number. + +The client takes optional parameters: + + * `--host` is the address of the gRPC server to connect to; default `localhost:16060`. + * `--web-host` is the address of Tinode web server, used for file uploads only; default `localhost:6060`. + * `--ssl` the server requires a secure connection (SSL) + * `--ssl-host` the domain name to use for SNI if different from the `--host` domain name. + * `--login-basic` is the `login:password` to be authenticated with. + * `--login-token` is the token to be authenticated with. + * `--login-cookie` direct the client to read the token from the cookie file `.tn-cli-cookie` generated during an earlier login. + * `--no-login` do not login even if cookie file is present; this is the default in non-interactive (scripted) mode. + * `--no-cookie` do not save cookie on successful login; this is the default in non-interactive (scripted) mode. + * `--api-key` web API key for file uploads; default `AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K` + * `--load-macros` path to a macro file. + * `--verbose` log incoming and outgoing messages as JSON. + * `--background` start interactive session in background; non-interactive sessions are always started in background. + +If multiple `login-XYZ` are provided, `login-cookie` is considered first, then `login-token` then `login-basic`. Authentication with token (and cookie) is much faster than with the username-password pair. + +## Commands + +Type ` -h` for help + +See some of these commands in use in the [sample-script.txt](sample-script.txt). Try it as +``` +python tn-cli.py < sample-script.txt +``` + +### Local (non-networking) + +* `.await` - issue a gRPC call and wait for completion, optionally assign result to a variable. +* `.delmark` - use custom delete marker instead of default `DEL!`; needed when some value is to be removed rather than set to blank. +* `.exit` - terminate execution and exit the CLI; also `.quit`. +* `.log` - write a value of a variable to `stdout`. +* `.must` - issue a gRPC call and wait for completion, optionally assign result to a variable; raise an exception if result is not a success. +* `.quit` - terminate execution and exit the CLI; also `.exit`. +* `.sleep` - suspend the process for a number of milliseconds. +* `.use` - set default user (on_behalf_of user) or topic. +* `.verbose` - toggle logging verbosity. + +### gRPC calls + +* `acc` - create or modify an account +* `login` - authenticate current session +* `sub` - subscribe to topic +* `leave` - detach or unsubscribe from topic +* `pub` - post message to topic +* `get` - query topic for metadata or messages +* `set` - update topic metadata +* `del` - delete message(s), topic, subscription, or user +* `note` - send notification +* `file` - upload or download large file out of band + +### HTTP requests + +* `upload` - (deprecated, use `file`) upload file out of band + +### Macros + +Macros are high-level wrappers for series of gRPC calls. Currently, the following macros are [available](macros.py): + +* `chacs` - change default permissions/acs for a user (requires root privileges) +* `chcred` - add or delete a credential for a user (requires root privileges) +* `passwd` - set user's password (requires root privileges) +* `resolve` - resolve login and print the corresponding user id +* `useradd` - create a new user account +* `userdel` - delete user account (requires root privileges) +* `usermod` - modify user account (requires root privileges) +* `thecard` - print user's public and private info (requires root privileges) + +You can define your own macros in [macros.py](macros.py) or create a separate python module (you can load it via `--load-macros`). +Refer to [macros.py](macros.py) for examples. + +## Connecting to secure (HTTPS) server + +If the server is configured to use TLS, i.e. running as `httpS://my-server.example.com/`, the gRPC endpoint also uses the same SSL certificate. In that case add the `--ssl` option. + +If you want to connect to the secure gRPC endpoint over a local network or under a different name i.e. as `localhost` instead of `my-server.example.com` in this example, you must specify the SSL domain name to use, otherwise the server will not be able to find the right SSL certificate: +``` +python tn-cli.py --host=localhost:6001 --ssl --ssl-host=my-server.example.com +``` +The `--ssl-host` option makes the connection susceptible to the [Man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack), so don't do it over public networks. + +## Crash on shutdown + +Python 3.6 sometimes crashes on shutdown with a message `Fatal Python error: PyImport_GetModuleDict: no module dictionary!`. That happens because Python is buggy: https://bugs.python.org/issue26153 diff --git a/tn-cli/client.py b/tn-cli/client.py index e69de29..c4f772f 100644 --- a/tn-cli/client.py +++ b/tn-cli/client.py @@ -0,0 +1,283 @@ +"""Tinode gRPC client operations and message handling.""" + +from __future__ import print_function + +import grpc +import json +import sys +import time + +from tinode_grpc import pb +from tinode_grpc import pbx + +import tn_globals +from tn_globals import printerr, stdoutln, to_json +from utils import dotdict + +# 5 seconds timeout for .await/.must commands. +AWAIT_TIMEOUT = 5 + + +# Handle {ctrl} server response +def handle_ctrl(ctrl): + # Run code on command completion + func = tn_globals.OnCompletion.get(ctrl.id) + if func: + del tn_globals.OnCompletion[ctrl.id] + if ctrl.code >= 200 and ctrl.code < 400: + func(ctrl.params) + + if tn_globals.WaitingFor and tn_globals.WaitingFor.await_id == ctrl.id: + if 'varname' in tn_globals.WaitingFor: + tn_globals.Variables[tn_globals.WaitingFor.varname] = ctrl + if tn_globals.WaitingFor.failOnError and ctrl.code >= 400: + raise Exception(str(ctrl.code) + " " + ctrl.text) + tn_globals.WaitingFor = None + + topic = " (" + str(ctrl.topic) + ")" if ctrl.topic else "" + stdoutln("\r<= " + str(ctrl.code) + " " + ctrl.text + topic) + + +# Lambda for handling login +def handle_login(params): + if params == None: + return None + + # Protobuf map 'params' is a map which is not a python object or a dictionary. Convert it. + nice = {} + for p in params: + nice[p] = json.loads(params[p]) + + stdoutln("Authenticated as", nice.get('user')) + + tn_globals.AuthToken = nice.get('token') + + return nice + + +# Save cookie to file after successful login. +def save_cookie(params): + if params == None: + return + + try: + cookie = open('.tn-cli-cookie', 'w') + json.dump(handle_login(params), cookie) + cookie.close() + except Exception as err: + stdoutln("Failed to save authentication cookie", err) + + +# Read cookie file for logging in with the cookie. +def read_cookie(): + try: + cookie = open('.tn-cli-cookie', 'r') + params = json.load(cookie) + cookie.close() + return params.get("token") + + except Exception as err: + printerr("Missing or invalid cookie file '.tn-cli-cookie'", err) + return None + + +def pop_from_output_queue(): + if tn_globals.OutputQueue.empty(): + return False + sys.stdout.write("\r<= "+tn_globals.OutputQueue.get()) + sys.stdout.flush() + return True + + +# Generator of protobuf messages. +def gen_message(scheme, secret, args): + """Client message generator: reads user input as string, + converts to pb.ClientMsg, and yields""" + import random + import threading + from input_handler import stdin + from commands import hiMsg, loginMsg, serialize_cmd + + random.seed() + id = random.randint(10000,60000) + + # Asynchronous input-output + tn_globals.InputThread = threading.Thread(target=stdin, args=(tn_globals.InputQueue,)) + tn_globals.InputThread.daemon = True + tn_globals.InputThread.start() + + try: + from importlib.metadata import version + except ImportError: + from importlib_metadata import version + import platform + + APP_NAME = "tn-cli" + APP_VERSION = "3.0.1" + LIB_VERSION = version("tinode_grpc") + GRPC_VERSION = version("grpcio") + + user_agent = APP_NAME + "/" + APP_VERSION + " (" + \ + platform.system() + "/" + platform.release() + "); gRPC-python/" + LIB_VERSION + "+" + GRPC_VERSION + + msg = hiMsg(id, args.background, user_agent, LIB_VERSION) + if tn_globals.Verbose: + stdoutln("\r=> " + to_json(msg)) + yield msg + + if scheme != None: + id += 1 + login = lambda:None + setattr(login, 'scheme', scheme) + setattr(login, 'secret', secret) + setattr(login, 'cred', None) + msg = loginMsg(id, login, args) + if tn_globals.Verbose: + stdoutln("\r=> " + to_json(msg)) + yield msg + + print_prompt = True + + while True: + try: + if not tn_globals.WaitingFor and tn_globals.InputQueue: + id += 1 + inp = tn_globals.InputQueue.popleft() + + if inp == 'exit' or inp == 'quit' or inp == '.exit' or inp == '.quit': + # Drain the output queue. + while pop_from_output_queue(): + pass + return + + pbMsg, cmd = serialize_cmd(inp, id, args) + print_prompt = tn_globals.IsInteractive + if isinstance(cmd, list): + # Push the expanded macro back on the command queue. + tn_globals.InputQueue.extendleft(reversed(cmd)) + continue + if pbMsg != None: + if not tn_globals.IsInteractive: + sys.stdout.write("=> " + inp + "\n") + sys.stdout.flush() + + if cmd.synchronous: + cmd.await_ts = time.time() + cmd.await_id = str(id) + tn_globals.WaitingFor = cmd + + if not hasattr(cmd, 'no_yield'): + if tn_globals.Verbose: + stdoutln("\r=> " + to_json(pbMsg)) + yield pbMsg + + elif not tn_globals.OutputQueue.empty(): + pop_from_output_queue() + print_prompt = tn_globals.IsInteractive + + else: + if print_prompt: + sys.stdout.write("tn> ") + sys.stdout.flush() + print_prompt = False + if tn_globals.WaitingFor: + if time.time() - tn_globals.WaitingFor.await_ts > AWAIT_TIMEOUT: + stdoutln("Timeout while waiting for '{0}' response".format(tn_globals.WaitingFor.cmd)) + tn_globals.WaitingFor = None + + if tn_globals.IsInteractive: + time.sleep(0.1) + else: + time.sleep(0.01) + + except Exception as err: + stdoutln("Exception in generator: {0}".format(err)) + + +# The main processing loop: send messages to server, receive responses. +def run(args, schema, secret): + failed = False + try: + from prompt_toolkit import PromptSession + + if tn_globals.IsInteractive: + tn_globals.Prompt = PromptSession() + # Create channel with default credentials. + tn_globals.Connection = None + if args.ssl: + opts = (('grpc.ssl_target_name_override', args.ssl_host),) if args.ssl_host else None + tn_globals.Connection = grpc.secure_channel(args.host, grpc.ssl_channel_credentials(), opts) + else: + tn_globals.Connection = grpc.insecure_channel(args.host) + + # Call the server + stream = pbx.NodeStub(tn_globals.Connection).MessageLoop(gen_message(schema, secret, args)) + + # Read server responses + for msg in stream: + if tn_globals.Verbose: + stdoutln("\r<= " + to_json(msg)) + + if msg.HasField("ctrl"): + handle_ctrl(msg.ctrl) + + elif msg.HasField("meta"): + what = [] + if len(msg.meta.sub) > 0: + what.append("sub") + if msg.meta.HasField("desc"): + what.append("desc") + if msg.meta.HasField("del"): + what.append("del") + if len(msg.meta.tags) > 0: + what.append("tags") + stdoutln("\r<= meta " + ",".join(what) + " " + msg.meta.topic) + + if tn_globals.WaitingFor and tn_globals.WaitingFor.await_id == msg.meta.id: + if 'varname' in tn_globals.WaitingFor: + tn_globals.Variables[tn_globals.WaitingFor.varname] = msg.meta + tn_globals.WaitingFor = None + + elif msg.HasField("data"): + stdoutln("\n\rFrom: " + msg.data.from_user_id) + stdoutln("Topic: " + msg.data.topic) + stdoutln("Seq: " + str(msg.data.seq_id)) + if msg.data.head: + stdoutln("Headers:") + for key in msg.data.head: + stdoutln("\t" + key + ": "+str(msg.data.head[key])) + stdoutln(json.loads(msg.data.content)) + + elif msg.HasField("pres"): + # 'ON', 'OFF', 'UA', 'UPD', 'GONE', 'ACS', 'TERM', 'MSG', 'READ', 'RECV', 'DEL', 'TAGS', 'AUX' + what = pb.ServerPres.What.Name(msg.pres.what) + stdoutln("\r<= pres " + what + " " + msg.pres.topic) + + elif msg.HasField("info"): + switcher = { + pb.READ: 'READ', + pb.RECV: 'RECV', + pb.KP: 'KP', + pb.CALL: 'CALL' + } + stdoutln("\rMessage #" + str(msg.info.seq_id) + " " + switcher.get(msg.info.what, "unknown") + + " by " + msg.info.from_user_id + "; topic=" + msg.info.topic + " (" + msg.topic + ")") + + else: + stdoutln("\rMessage type not handled" + str(msg)) + + except grpc.RpcError as err: + # print(err) + printerr("gRPC failed with {0}: {1}".format(err.code(), err.details())) + failed = True + except Exception as ex: + printerr("Request failed: {0}".format(ex)) + failed = True + finally: + from tn_globals import printout + printout('Shutting down...') + tn_globals.Connection.close() + if tn_globals.InputThread != None: + tn_globals.InputThread.join(0.3) + + return 1 if failed else 0 diff --git a/tn-cli/commands.py b/tn-cli/commands.py index e69de29..d1e6278 100644 --- a/tn-cli/commands.py +++ b/tn-cli/commands.py @@ -0,0 +1,800 @@ +"""Command parsing and message construction for tn-cli.""" + +from __future__ import print_function + +import argparse +import base64 +import json +import mimetypes +import os +import re +import requests +import shlex +import threading +import time + +from tinode_grpc import pb +from tinode_grpc import pbx + +import tn_globals +from tn_globals import printout, stdoutln +from utils import ( + makeTheCard, inline_image, attachment, encode_to_bytes, + parse_cred, parse_trusted, dotdict, DELETE_MARKER, TINODE_DEL +) + +APP_NAME = "tn-cli" +APP_VERSION = "3.0.1" +PROTOCOL_VERSION = "0" + +# Regex to match and parse subscripted entries in variable paths. +RE_INDEX = re.compile(r"(\w+)\[(\w+)\]") + +# Macros module (may be None). +macros = None + + +def set_macros_module(m): + """Set the macros module for use in command parsing.""" + global macros + macros = m + + +# Create proto for ClientExtra +def pack_extra(cmd): + return pb.ClientExtra(on_behalf_of=tn_globals.DefaultUser, auth_level=pb.ROOT if cmd.as_root else pb.NONE) + + +# Read a value in the server response using dot notation, i.e. +# $user.params.token or $meta.sub[1].user +def getVar(path): + if not path.startswith("$"): + return path + + parts = path.split('.') + if parts[0] not in tn_globals.Variables: + return None + var = tn_globals.Variables[parts[0]] + if len(parts) > 1: + parts = parts[1:] + for p in parts: + x = None + m = RE_INDEX.match(p) + if m: + p = m.group(1) + if m.group(2).isdigit(): + x = int(m.group(2)) + else: + x = m.group(2) + var = getattr(var, p) + if x or x == 0: + var = var[x] + if isinstance(var, bytes): + var = var.decode('utf-8') + return var + + +# Dereference values, i.e. cmd.val == $usr => cmd.val == +def derefVals(cmd): + for key in dir(cmd): + if not key.startswith("__") and key != 'varname': + val = getattr(cmd, key) + if type(val) is str and val.startswith("$"): + setattr(cmd, key, getVar(val)) + return cmd + + +# Constructing individual messages +# {hi} +def hiMsg(id, background, user_agent, lib_version): + tn_globals.OnCompletion[str(id)] = lambda params: print_server_params(params) + return pb.ClientMsg(hi=pb.ClientHi(id=str(id), user_agent=user_agent, + ver=lib_version, lang="EN", background=background)) + + +# {acc} +def accMsg(id, cmd, ignored): + if cmd.uname: + cmd.scheme = 'basic' + if cmd.password == None: + cmd.password = '' + cmd.secret = str(cmd.uname) + ":" + str(cmd.password) + + if cmd.secret: + if cmd.scheme == None: + cmd.scheme = 'basic' + cmd.secret = cmd.secret.encode('utf-8') + else: + cmd.secret = b'' + + state = None + if cmd.suspend == 'true': + state = 'susp' + elif cmd.suspend == 'false': + state = 'ok' + + cmd.public = encode_to_bytes(makeTheCard(cmd.fn, cmd.note, cmd.photo)) + cmd.private = encode_to_bytes(cmd.private) + return pb.ClientMsg(acc=pb.ClientAcc(id=str(id), user_id=cmd.user, state=state, + scheme=cmd.scheme, secret=cmd.secret, login=cmd.do_login, tags=cmd.tags.split(",") if cmd.tags else None, + desc=pb.SetDesc(default_acs=pb.DefaultAcsMode(auth=cmd.auth, anon=cmd.anon), + public=cmd.public, private=cmd.private, trusted=encode_to_bytes(parse_trusted(cmd.trusted))), + cred=parse_cred(cmd.cred)), + extra=pack_extra(cmd)) + + +# {login} +def loginMsg(id, cmd, args): + if cmd.secret == None: + if cmd.uname == None: + cmd.uname = '' + if cmd.password == None: + cmd.password = '' + cmd.secret = str(cmd.uname) + ":" + str(cmd.password) + cmd.secret = cmd.secret.encode('utf-8') + elif cmd.scheme == "basic": + # Assuming secret is a uname:password string. + cmd.secret = str(cmd.secret).encode('utf-8') + else: + # All other schemes: assume secret is a base64-encoded string + cmd.secret = base64.b64decode(cmd.secret) + + from client import handle_login, save_cookie + msg = pb.ClientMsg(login=pb.ClientLogin(id=str(id), scheme=cmd.scheme, secret=cmd.secret, + cred=parse_cred(cmd.cred))) + + if args.no_cookie or not tn_globals.IsInteractive: + tn_globals.OnCompletion[str(id)] = lambda params: handle_login(params) + else: + tn_globals.OnCompletion[str(id)] = lambda params: save_cookie(params) + + return msg + + +# {sub} +def subMsg(id, cmd, ignored): + if not cmd.topic: + cmd.topic = tn_globals.DefaultTopic + if cmd.get_query: + cmd.get_query = pb.GetQuery(what=" ".join(cmd.get_query.split(","))) + cmd.public = encode_to_bytes(makeTheCard(cmd.fn, cmd.note, cmd.photo)) + cmd.private = TINODE_DEL if cmd.private == DELETE_MARKER else encode_to_bytes(cmd.private) + return pb.ClientMsg(sub=pb.ClientSub(id=str(id), topic=cmd.topic, + set_query=pb.SetQuery( + desc=pb.SetDesc(public=cmd.public, private=cmd.private, + trusted=encode_to_bytes(parse_trusted(cmd.trusted)), + default_acs=pb.DefaultAcsMode(auth=cmd.auth, anon=cmd.anon)), + sub=pb.SetSub(mode=cmd.mode), + tags=cmd.tags.split(",") if cmd.tags else None), + get_query=cmd.get_query), + extra=pack_extra(cmd)) + + +# {leave} +def leaveMsg(id, cmd, ignored): + if not cmd.topic: + cmd.topic = tn_globals.DefaultTopic + return pb.ClientMsg(leave=pb.ClientLeave(id=str(id), topic=cmd.topic, unsub=cmd.unsub), + extra=pack_extra(cmd)) + + +# {pub} +def pubMsg(id, cmd, ignored): + if not cmd.topic: + cmd.topic = tn_globals.DefaultTopic + + head = {} + if cmd.drafty or cmd.image or cmd.attachment: + head['mime'] = encode_to_bytes('text/x-drafty') + + # Excplicitly provided 'mime' will override the one assigned above. + if cmd.head: + for h in cmd.head.split(","): + key, val = h.split(":") + head[key] = encode_to_bytes(val) + + content = json.loads(cmd.drafty) if cmd.drafty \ + else inline_image(cmd.image) if cmd.image \ + else attachment(cmd.attachment) if cmd.attachment \ + else cmd.content + + if not content: + return None + + return pb.ClientMsg(pub=pb.ClientPub(id=str(id), topic=cmd.topic, no_echo=True, + head=head, content=encode_to_bytes(content)), + extra=pack_extra(cmd)) + + +# {get} +def getMsg(id, cmd, ignored): + if not cmd.topic: + cmd.topic = tn_globals.DefaultTopic + + what = [] + if cmd.desc: + what.append("desc") + if cmd.sub: + what.append("sub") + if cmd.tags: + what.append("tags") + if cmd.data: + what.append("data") + if cmd.cred: + what.append("cred") + return pb.ClientMsg(get=pb.ClientGet(id=str(id), topic=cmd.topic, + query=pb.GetQuery(what=" ".join(what))), + extra=pack_extra(cmd)) + + +# {set} +def setMsg(id, cmd, ignored): + if not cmd.topic: + cmd.topic = tn_globals.DefaultTopic + + if cmd.public == None: + cmd.public = encode_to_bytes(makeTheCard(cmd.fn, cmd.note, cmd.photo)) + else: + cmd.public = TINODE_DEL if cmd.public == DELETE_MARKER else encode_to_bytes(cmd.public) + cmd.private = TINODE_DEL if cmd.private == DELETE_MARKER else encode_to_bytes(cmd.private) + cred = parse_cred(cmd.cred) + if cred: + if len(cred) > 1: + stdoutln('Warning: multiple credentials specified. Will use only the first one.') + cred = cred[0] + + return pb.ClientMsg(set=pb.ClientSet(id=str(id), topic=cmd.topic, + query=pb.SetQuery( + desc=pb.SetDesc(default_acs=pb.DefaultAcsMode(auth=cmd.auth, anon=cmd.anon), + public=cmd.public, private=cmd.private, + trusted=encode_to_bytes(parse_trusted(cmd.trusted))), + sub=pb.SetSub(user_id=cmd.user, mode=cmd.mode), + tags=cmd.tags.split(",") if cmd.tags else None, + cred=cred)), + extra=pack_extra(cmd)) + + +# {del} +def delMsg(id, cmd, ignored): + if not cmd.what: + stdoutln("Must specify what to delete") + return None + + enum_what = None + before = None + seq_list = None + cred = None + if cmd.what == 'msg': + enum_what = pb.ClientDel.MSG + cmd.topic = cmd.topic if cmd.topic else tn_globals.DefaultTopic + if not cmd.topic: + stdoutln("Must specify topic to delete messages") + return None + if cmd.user: + stdoutln("Unexpected '--user' parameter") + return None + if not cmd.seq: + stdoutln("Must specify message IDs to delete") + return None + + if cmd.seq == 'all': + seq_list = [pb.SeqRange(low=1, hi=0x8FFFFFF)] + else: + # Split a list like '1,2,3,10-22' into ranges. + try: + seq_list = [] + for item in cmd.seq.split(','): + if '-' in item: + low, hi = [int(x.strip()) for x in item.split('-')] + if low>=hi or low<=0: + stdoutln("Invalid message ID range {0}-{1}".format(low, hi)) + return None + seq_list.append(pb.SeqRange(low=low, hi=hi)) + else: + seq_list.append(pb.SeqRange(low=int(item.strip()))) + except ValueError as err: + stdoutln("Invalid message IDs: {0}".format(err)) + return None + + elif cmd.what == 'sub': + cmd.topic = cmd.topic if cmd.topic else tn_globals.DefaultTopic + cmd.user = cmd.user if cmd.user else tn_globals.DefaultUser + if not cmd.user or not cmd.topic: + stdoutln("Must specify topic and user to delete subscription") + return None + enum_what = pb.ClientDel.SUB + + elif cmd.what == 'topic': + cmd.topic = cmd.topic if cmd.topic else tn_globals.DefaultTopic + if cmd.user: + stdoutln("Unexpected '--user' parameter") + return None + if not cmd.topic: + stdoutln("Must specify topic to delete") + return None + enum_what = pb.ClientDel.TOPIC + + elif cmd.what == 'user': + cmd.user = cmd.user if cmd.user else tn_globals.DefaultUser + if cmd.topic: + stdoutln("Unexpected '--topic' parameter") + return None + enum_what = pb.ClientDel.USER + + elif cmd.what == 'cred': + if cmd.user: + stdoutln("Unexpected '--user' parameter") + return None + if cmd.topic != 'me': + stdoutln("Topic must be 'me'") + return None + cred = parse_cred(cmd.cred) + if cred is None: + stdoutln("Failed to parse credential '{0}'".format(cmd.cred)) + return None + cred = cred[0] + enum_what = pb.ClientDel.CRED + + else: + stdoutln("Unrecognized delete option '", cmd.what, "'") + return None + + msg = pb.ClientMsg(extra=pack_extra(cmd)) + # Field named 'del' conflicts with the keyword 'del. This is a work around. + xdel = getattr(msg, 'del') + """ + setattr(msg, 'del', pb.ClientDel(id=str(id), topic=topic, what=enum_what, hard=hard, + del_seq=seq_list, user_id=user)) + """ + xdel.id = str(id) + xdel.what = enum_what + if cmd.hard != None: + xdel.hard = cmd.hard + if seq_list != None: + xdel.del_seq.extend(seq_list) + if cmd.user != None: + xdel.user_id = cmd.user + if cmd.topic != None: + xdel.topic = cmd.topic + if cred != None: + xdel.cred.MergeFrom(cred) + + return msg + + +# {note} +def noteMsg(id, cmd, ignored): + if not cmd.topic: + cmd.topic = tn_globals.DefaultTopic + + enum_what = None + cmd.seq = int(cmd.seq) + if cmd.what == 'kp': + enum_what = pb.KP + cmd.seq = None + elif cmd.what == 'read': + enum_what = pb.READ + elif cmd.what == 'recv': + enum_what = pb.RECV + elif cmd.what == 'call': + enum_what = pb.CALL + + enum_event = None + if enum_what == pb.CALL: + if cmd.what == 'accept': + enum_event = pb.ACCEPT + elif cmd.what == 'answer': + enum_event = pb.ANSWER + elif cmd.what == 'ice-candidate': + enum_event = pb.ICE_CANDIDATE + elif cmd.what == 'hang-up': + enum_event = pb.HANG_UP + elif cmd.what == 'offer': + enum_event = pb.OFFER + elif cmd.what == 'ringing': + enum_event = pb.RINGING + else: + cmd.payload = None + + return pb.ClientMsg(note=pb.ClientNote(topic=cmd.topic, what=enum_what, + seq_id=cmd.seq, event=enum_event, payload=cmd.payload), + extra=pack_extra(cmd)) + + +# Upload file out of band over HTTP(S) (not gRPC). +def upload(id, cmd, args): + try: + from client import handle_ctrl + scheme = 'https' if args.ssl else 'http' + try: + from importlib.metadata import version + except ImportError: + from importlib_metadata import version + LIB_VERSION = version("tinode_grpc") + + result = requests.post( + scheme + '://' + args.web_host + '/v' + PROTOCOL_VERSION + '/file/u/', + headers = { + 'X-Tinode-APIKey': args.api_key, + 'X-Tinode-Auth': 'Token ' + tn_globals.AuthToken, + 'User-Agent': APP_NAME + " " + APP_VERSION + "/" + LIB_VERSION + }, + data = {'id': id}, + files = {'file': (cmd.filename, open(cmd.filename, 'rb'))}) + handle_ctrl(dotdict(json.loads(result.text)['ctrl'])) + + except Exception as ex: + stdoutln("Failed to upload '{0}'".format(cmd.filename), ex) + + return None + + +def fileUpload(id, cmd, args): + def iter_file(filepath, size=1024*1024): + _, name = os.path.split(filepath) + mimeType = mimetypes.guess_type(filepath)[0] + with open(filepath, mode='rb') as fd: + try: + yield pb.FileUpReq(id=str(id), auth=pb.Auth(scheme='token', secret=tn_globals.AuthToken), + topic="", meta=pb.FileMeta(name=name, mime_type=mimeType, size=0)) + while True: + chunk = fd.read(size) + if chunk: + yield pb.FileUpReq(content=chunk) + else: # Finished. + break + except Exception as ex: + stdoutln("Failed to read '{0}':".format(cmd.filename), ex) + + try: + response = pbx.NodeStub(tn_globals.Connection).LargeFileReceive(iter_file(cmd.filename)) + if response.code == 200: + stdoutln("Upload OK: '{0}' ({1}), size={2}" + .format(response.meta.name, response.meta.mime_type, response.meta.size)) + else: + stdoutln("Upload failed: {0} {1}".format(response.code, response.text)) + except Exception as ex: + stdoutln("Failed to upload '{0}':".format(cmd.filename), ex) + + +def fileDownload(id, cmd, args): + req = pb.FileDownReq(id=str(id), auth=pb.Auth(scheme='token', secret=tn_globals.AuthToken), + uri=cmd.filename, if_modified="") + + # Call the server + stream = pbx.NodeStub(tn_globals.Connection).LargeFileServe(req) + # Read file chunks + fd = None + for chunk in stream: + if chunk: + if chunk.code >= 400: + stdoutln("Failed to download '{0}': {1} {2}".format(cmd.filename, chunk.code, chunk.text)) + break + if chunk.code >= 300: + stdoutln("Use HTTP {0} to download from {1}".format(chunk.code, chunk.redir_url)) + break + if not fd: + fd = open(chunk.meta.name, mode='wb') + fd.write(chunk.content) + continue + if fd: + fd.close() + + +# Given an array of parts, parse commands and arguments +def parse_cmd(parts): + parser = None + if parts[0] == "acc": + parser = argparse.ArgumentParser(prog=parts[0], description='Create or alter an account') + parser.add_argument('--user', default='new', help='ID of the account to update') + parser.add_argument('--scheme', default=None, help='authentication scheme, default=basic') + parser.add_argument('--secret', default=None, help='secret for authentication') + parser.add_argument('--uname', default=None, help='user name for basic authentication') + parser.add_argument('--password', default=None, help='password for basic authentication') + parser.add_argument('--do-login', action='store_true', help='login with the newly created account') + parser.add_argument('--tags', action=None, help='tags for user discovery, comma separated list without spaces') + parser.add_argument('--fn', default=None, help='user\'s human name') + parser.add_argument('--photo', default=None, help='avatar file name') + parser.add_argument('--private', default=None, help='user\'s private info') + parser.add_argument('--note', default=None, help='user\'s description') + parser.add_argument('--trusted', default=None, help='trusted markers: verified, staff, danger, prepend with rm- to remove, e.g. rm-verified') + parser.add_argument('--auth', default=None, help='default access mode for authenticated users') + parser.add_argument('--anon', default=None, help='default access mode for anonymous users') + parser.add_argument('--cred', default=None, help='credentials, comma separated list in method:value format, e.g. email:test@example.com,tel:12345') + parser.add_argument('--suspend', default=None, help='true to suspend the account, false to un-suspend') + elif parts[0] == "del": + parser = argparse.ArgumentParser(prog=parts[0], description='Delete message(s), subscription, topic, user') + parser.add_argument('what', default=None, help='what to delete') + parser.add_argument('--topic', default=None, help='topic being affected') + parser.add_argument('--user', default=None, help='either delete this user or a subscription with this user') + parser.add_argument('--seq', default=None, help='"all" or a list of comma- and dash-separated message IDs to delete, e.g. "1,2,9-12"') + parser.add_argument('--hard', action='store_true', help='request to hard-delete') + parser.add_argument('--cred', help='credential to delete in method:value format, e.g. email:test@example.com, tel:12345') + elif parts[0] == "file": + parser = argparse.ArgumentParser(prog=parts[0], description='Download or upload a large file') + parser.add_argument('--what', default='down', choices=['down', 'up'], help='download \'down\' or upload \'up\'') + parser.add_argument('filename', help='name of the file to upload') + elif parts[0] == "get": + parser = argparse.ArgumentParser(prog=parts[0], description='Query topic for messages or metadata') + parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to query') + parser.add_argument('--topic', dest='topic', default=None, help='topic to query') + parser.add_argument('--desc', action='store_true', help='query topic description') + parser.add_argument('--sub', action='store_true', help='query topic subscriptions') + parser.add_argument('--tags', action='store_true', help='query topic tags') + parser.add_argument('--data', action='store_true', help='query topic messages') + parser.add_argument('--cred', action='store_true', help='query account credentials') + elif parts[0] == "leave": + parser = argparse.ArgumentParser(prog=parts[0], description='Detach or unsubscribe from topic') + parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to detach from') + parser.add_argument('--topic', dest='topic', default=None, help='topic to detach from') + parser.add_argument('--unsub', action='store_true', help='detach and unsubscribe from topic') + elif parts[0] == "login": + parser = argparse.ArgumentParser(prog=parts[0], description='Authenticate current session') + parser.add_argument('secret', nargs='?', default=argparse.SUPPRESS, help='secret for authentication') + parser.add_argument('--scheme', default='basic', help='authentication schema, default=basic') + parser.add_argument('--secret', dest='secret', default=None, help='secret for authentication') + parser.add_argument('--uname', default=None, help='user name in basic authentication scheme') + parser.add_argument('--password', default=None, help='password in basic authentication scheme') + parser.add_argument('--cred', default=None, help='credentials, comma separated list in method:value:response format, e.g. email:test@example.com,tel:12345') + elif parts[0] == "note": + parser = argparse.ArgumentParser(prog=parts[0], description='Send notification to topic, ex "note kp"') + parser.add_argument('topic', help='topic to notify') + parser.add_argument('what', nargs='?', default='kp', const='kp', choices=['call', 'kp', 'read', 'recv'], + help='notification type: kp (key press), recv, read - message received or read receipt') + parser.add_argument('--seq', help='message ID being reported') + parser.add_argument('--event', help='video call event', choices=['accept', 'answer', 'ice-candidate', 'hang-up', 'offer', 'ringing']) + parser.add_argument('--payload', help='video call payload') + elif parts[0] == "pub": + parser = argparse.ArgumentParser(prog=parts[0], description='Send message to topic') + parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to publish to') + parser.add_argument('--topic', dest='topic', default=None, help='topic to publish to') + parser.add_argument('content', nargs='?', default=argparse.SUPPRESS, help='message to send') + parser.add_argument('--head', help='message headers') + parser.add_argument('--content', dest='content', help='message to send') + parser.add_argument('--drafty', help='structured message to send, e.g. drafty content') + parser.add_argument('--image', help='image file to insert into message (not implemented yet)') + parser.add_argument('--attachment', help='file to send as an attachment (not implemented yet)') + elif parts[0] == "set": + parser = argparse.ArgumentParser(prog=parts[0], description='Update topic metadata') + parser.add_argument('topic', help='topic to update') + parser.add_argument('--fn', help='topic\'s title') + parser.add_argument('--photo', help='avatar file name') + parser.add_argument('--public', help='topic\'s public info, alternative to fn+photo+note') + parser.add_argument('--private', help='topic\'s private info') + parser.add_argument('--note', default=None, help='topic\'s description') + parser.add_argument('--trusted', default=None, help='trusted markers: verified, staff, danger') + parser.add_argument('--auth', help='default access mode for authenticated users') + parser.add_argument('--anon', help='default access mode for anonymous users') + parser.add_argument('--user', help='ID of the account to update') + parser.add_argument('--mode', help='new value of access mode') + parser.add_argument('--tags', help='tags for topic discovery, comma separated list without spaces') + parser.add_argument('--cred', help='credential to add in method:value format, e.g. email:test@example.com, tel:12345') + elif parts[0] == "sub": + parser = argparse.ArgumentParser(prog=parts[0], description='Subscribe to topic') + parser.add_argument('topic', nargs='?', default=argparse.SUPPRESS, help='topic to subscribe to') + parser.add_argument('--topic', dest='topic', default=None, help='topic to subscribe to') + parser.add_argument('--fn', default=None, help='topic\'s user-visible name') + parser.add_argument('--photo', default=None, help='avatar file name') + parser.add_argument('--private', default=None, help='topic\'s private info') + parser.add_argument('--note', default=None, help='topic\'s description') + parser.add_argument('--trusted', default=None, help='trusted markers: verified, staff, danger') + parser.add_argument('--auth', default=None, help='default access mode for authenticated users') + parser.add_argument('--anon', default=None, help='default access mode for anonymous users') + parser.add_argument('--mode', default=None, help='new value of access mode') + parser.add_argument('--tags', default=None, help='tags for topic discovery, comma separated list without spaces') + parser.add_argument('--get-query', default=None, help='query for topic metadata or messages, comma separated list without spaces') + elif parts[0] == "upload": + parser = argparse.ArgumentParser(prog=parts[0], description='Upload file out of band over HTTP(S)') + parser.add_argument('filename', help='name of the file to upload') + elif macros: + parser = macros.parse_macro(parts) + + if parser: + try: + parser.add_argument('--as_root', action='store_true', help='execute command at ROOT auth level') + except Exception: + # Ignore exception here: --as_root has been added already, macro parser is persistent. + pass + return parser + + +# Parses command line into command and parameters. +def parse_input(cmd): + # Split line into parts using shell-like syntax. + try: + parts = shlex.split(cmd, comments=True) + except Exception as err: + printout('Error parsing command: ', err) + return None + if len(parts) == 0: + return None + + parser = None + varname = None + synchronous = False + failOnError = False + + if parts[0] == ".use": + parser = argparse.ArgumentParser(prog=parts[0], description='Set default user or topic') + parser.add_argument('--user', default="unchanged", help='ID of default (on_behalf_of) user') + parser.add_argument('--topic', default="unchanged", help='Name of default topic') + + elif parts[0] == ".await" or parts[0] == ".must": + # .await|.must [<$variable_name>] + if len(parts) > 1: + synchronous = True + failOnError = parts[0] == ".must" + if len(parts) > 2 and parts[1][0] == '$': + # Varname is given + varname = parts[1] + parts = parts[2:] + parser = parse_cmd(parts) + else: + # No varname + parts = parts[1:] + parser = parse_cmd(parts) + + elif parts[0] == ".log": + parser = argparse.ArgumentParser(prog=parts[0], description='Write value of a variable to stdout') + parser.add_argument('varname', help='name of the variable to print') + + elif parts[0] == ".sleep": + parser = argparse.ArgumentParser(prog=parts[0], description='Pause execution') + parser.add_argument('millis', type=int, help='milliseconds to wait') + + elif parts[0] == ".verbose": + parser = argparse.ArgumentParser(prog=parts[0], description='Toggle logging verbosity') + + elif parts[0] == ".delmark": + parser = argparse.ArgumentParser(prog=parts[0], description='Use custom delete maker instead of default DEL!') + parser.add_argument('delmark', help='marker to use') + + else: + parser = parse_cmd(parts) + + if not parser: + printout("Unrecognized:", parts[0]) + printout("Possible commands:") + printout("\t.await\t\t- wait for completion of an operation") + printout("\t.delmark\t- custom delete marker to use instead of default DEL!") + printout("\t.exit\t\t- exit the program (also .quit)") + printout("\t.log\t\t- write value of a variable to stdout") + printout("\t.must\t\t- wait for completion of an operation, terminate on failure") + printout("\t.sleep\t\t- pause execution") + printout("\t.use\t\t- set default user (on_behalf_of) or topic") + printout("\t.verbose\t- toggle logging verbosity on/off") + printout("\tacc\t\t- create or alter an account") + printout("\tdel\t\t- delete message(s), topic, subscription, or user") + printout("\tfile\t\t- download or upload a large file") + printout("\tget\t\t- query topic for metadata or messages") + printout("\tleave\t\t- detach or unsubscribe from topic") + printout("\tlogin\t\t- authenticate current session") + printout("\tnote\t\t- send a notification") + printout("\tpub\t\t- post message to topic") + printout("\tset\t\t- update topic metadata") + printout("\tsub\t\t- subscribe to topic") + printout("\tupload\t\t- upload file out of band over HTTP(S)") + printout("\tusermod\t\t- modify user account") + printout("\n\tType -h for help") + + if macros: + printout("\nMacro commands:") + for key in sorted(macros.Macros): + macro = macros.Macros[key] + printout("\t%s\t\t- %s" % (macro.name(), macro.description())) + return None + + try: + args = parser.parse_args(parts[1:]) + args.cmd = parts[0] + args.synchronous = synchronous + args.failOnError = failOnError + if varname: + args.varname = varname + return args + + except SystemExit: + return None + + +# Process command-line input string: execute local commands, generate +# protobuf messages for remote commands. +def serialize_cmd(string, id, args): + """Take string read from the command line, convert in into a protobuf message""" + global DELETE_MARKER + + messages = { + "acc": accMsg, + "login": loginMsg, + "sub": subMsg, + "leave": leaveMsg, + "pub": pubMsg, + "get": getMsg, + "set": setMsg, + "del": delMsg, + "note": noteMsg, + } + try: + # Convert string into a dictionary + cmd = parse_input(string) + if cmd == None: + return None, None + + elif cmd.cmd == "file": + # Start async upload + target = fileUpload if cmd.what == 'up' else fileDownload + upload_thread = threading.Thread(target=target, args=(id, derefVals(cmd), args), name="file_"+cmd.filename) + upload_thread.start() + cmd.no_yield = True + return True, cmd + + # Process dictionary + elif cmd.cmd == ".log": + stdoutln(getVar(cmd.varname)) + return None, None + + elif cmd.cmd == ".use": + if cmd.user != "unchanged": + if cmd.user: + if len(cmd.user) > 3 and cmd.user.startswith("usr"): + tn_globals.DefaultUser = cmd.user + else: + stdoutln("Error: user ID '{}' is invalid".format(cmd.user)) + else: + tn_globals.DefaultUser = None + stdoutln("Default user='{}'".format(tn_globals.DefaultUser)) + + if cmd.topic != "unchanged": + if cmd.topic: + if cmd.topic[:3] in ['me', 'fnd', 'sys', 'usr', 'grp', 'chn']: + tn_globals.DefaultTopic = cmd.topic + else: + stdoutln("Error: topic '{}' is invalid".format(cmd.topic)) + else: + tn_globals.DefaultTopic = None + stdoutln("Default topic='{}'".format(tn_globals.DefaultTopic)) + + return None, None + + elif cmd.cmd == ".sleep": + stdoutln("Pausing for {}ms...".format(cmd.millis)) + time.sleep(cmd.millis/1000.) + return None, None + + elif cmd.cmd == ".verbose": + tn_globals.Verbose = not tn_globals.Verbose + stdoutln("Logging is {}".format("verbose" if tn_globals.Verbose else "normal")) + return None, None + + elif cmd.cmd == ".delmark": + DELETE_MARKER = cmd.delmark + stdoutln("Using {} as delete marker".format(DELETE_MARKER)) + return None, None + + elif cmd.cmd == "upload": + # Start async upload + upload_thread = threading.Thread(target=upload, args=(id, derefVals(cmd), args), name="Uploader_"+cmd.filename) + upload_thread.start() + cmd.no_yield = True + return True, cmd + + elif cmd.cmd in messages: + return messages[cmd.cmd](id, derefVals(cmd), args), cmd + elif macros and cmd.cmd in macros.Macros: + return True, macros.Macros[cmd.cmd].run(id, derefVals(cmd), args) + + else: + stdoutln("Error: unrecognized: '{}'".format(cmd.cmd)) + return None, None + + except Exception as err: + stdoutln("Error in '{0}': {1}".format(string, err)) + return None, None + + +# Log server info. +def print_server_params(params): + servParams = [] + for p in params: + servParams.append(p + ": " + str(json.loads(params[p]))) + stdoutln("\r<= Connected to server: " + "; ".join(servParams)) diff --git a/tn-cli/input_handler.py b/tn-cli/input_handler.py index e69de29..0b7d0c0 100644 --- a/tn-cli/input_handler.py +++ b/tn-cli/input_handler.py @@ -0,0 +1,65 @@ +"""User input handling for tn-cli.""" + +from __future__ import print_function + +import sys + +import tn_globals +from tn_globals import printerr + + +# Prints prompt and reads lines from stdin. +def readLinesFromStdin(): + if tn_globals.IsInteractive: + while True: + try: + line = tn_globals.Prompt.prompt() + yield line + except EOFError as e: + # Ctrl+D. + break + else: + # iter(...) is a workaround for a python2 bug https://bugs.python.org/issue3907 + for cmd in iter(sys.stdin.readline, ''): + yield cmd + + +# Stdin reads a possibly multiline input from stdin and queues it for asynchronous processing. +def stdin(InputQueue): + partial_input = "" + try: + for cmd in readLinesFromStdin(): + cmd = cmd.strip() + # Check for continuation symbol \ in the end of the line. + if len(cmd) > 0 and cmd[-1] == "\\": + cmd = cmd[:-1].rstrip() + if cmd: + if partial_input: + partial_input += " " + cmd + else: + partial_input = cmd + + if tn_globals.IsInteractive: + sys.stdout.write("... ") + sys.stdout.flush() + + continue + + # Check if we have cached input from a previous multiline command. + if partial_input: + if cmd: + partial_input += " " + cmd + InputQueue.append(partial_input) + partial_input = "" + continue + + InputQueue.append(cmd) + + # Stop processing input + if cmd == 'exit' or cmd == 'quit' or cmd == '.exit' or cmd == '.quit': + return + + except Exception as ex: + printerr("Exception in stdin", ex) + + InputQueue.append('exit') diff --git a/tn-cli/macros.py b/tn-cli/macros.py index e69de29..859b1fc 100644 --- a/tn-cli/macros.py +++ b/tn-cli/macros.py @@ -0,0 +1,340 @@ +"""Tinode command line macro definitions.""" + +import argparse +import tn_globals +from tn_globals import stdoutln + + +class Macro: + """Macro base class. The external callers are expected to access + * self.parser - an instance of argparse.Argument parser which attempts to + turn a list of tokens into the corresponding argparse command. + * self.run() - executes the macro as instructed by the user.""" + + def __init__(self): + self.parser = argparse.ArgumentParser(prog=self.name(), description=self.description()) + self.add_parser_args() + # Explain argument. + self.parser.add_argument('--explain', action='store_true', help='Only print out expanded macro') + def name(self): + """Macro name.""" + pass + + def description(self): + """Macro description.""" + pass + + def add_parser_args(self): + """Method which adds custom command line arguments.""" + pass + + def expand(self, id, cmd, args): + """Expands the macro to a list of basic Tinode CLI commands.""" + pass + + def run(self, id, cmd, args): + """Expands the macro and returns the list of commands to actually execute to the caller + depending on the presence of the --explain argument. + """ + cmds = self.expand(id, cmd, args) + if cmd.explain: + if cmds is None: + return None + for item in cmds: + stdoutln(item) + return [] + return cmds + + +class Usermod(Macro): + """Modifies user account. The following modes are available: + * suspend/unsuspend account. + * change user's theCard (public name, description, avatar), private comment, trusted values. + + This macro requires root privileges.""" + + def name(self): + return "usermod" + + def description(self): + return 'Modify user account (requires root privileges)' + + def add_parser_args(self): + self.parser.add_argument('userid', help='user to update') + self.parser.add_argument('-L', '--suspend', action='store_true', help='Suspend account') + self.parser.add_argument('-U', '--unsuspend', action='store_true', help='Unsuspend account') + self.parser.add_argument('--name', help='Public name') + self.parser.add_argument('--avatar', help='Avatar file name') + self.parser.add_argument('--comment', help='Private comment on account') + self.parser.add_argument('--note', help='Account description') + self.parser.add_argument('--trusted', help='Add/remove trusted marker: verified, staff, danger') + + def expand(self, id, cmd, args): + if not cmd.userid: + return None + + # Suspend/unsuspend user. + if cmd.suspend or cmd.unsuspend: + if cmd.suspend and cmd.unsuspend: + stdoutln("Cannot both suspend and unsuspend account") + return None + new_cmd = 'acc --user %s --as_root' % cmd.userid + if cmd.suspend: + new_cmd += ' --suspend true' + if cmd.unsuspend: + new_cmd += ' --suspend false' + return [new_cmd] + + # Change theCard. + varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp' + set_cmd = '.must ' + varname + ' set me' + if cmd.name is not None: + set_cmd += ' --fn="%s"' % cmd.name + if cmd.avatar is not None: + set_cmd += ' --photo="%s"' % cmd.avatar + if cmd.comment is not None: + set_cmd += ' --private="%s"' % cmd.comment + if cmd.note is not None: + set_cmd += ' --note="%s"' % cmd.note + if cmd.trusted is not None: + set_cmd += ' --trusted="%s" --as_root' % cmd.trusted + old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else '' + return ['.use --user %s' % cmd.userid, + '.must sub me', + set_cmd, + '.must leave me', + '.use --user "%s"' % old_user] + + +class Resolve(Macro): + """Looks up user id by login name and prints it.""" + + def name(self): + return "resolve" + + def description(self): + return "Resolve login and print the corresponding user id" + + def add_parser_args(self): + self.parser.add_argument('login', help='login to resolve') + + def expand(self, id, cmd, args): + if not cmd.login: + return None + + varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp' + return ['.must sub fnd', + '.must set fnd --public=basic:%s' % cmd.login, + '.must %s get fnd --sub' % varname, + '.must leave fnd', + '.log %s.sub[0].topic' % varname] + + +class Passwd(Macro): + """Sets user's password (requires root privileges).""" + + def name(self): + return "passwd" + + def description(self): + return "Set user's password (requires root privileges)" + + def add_parser_args(self): + self.parser.add_argument('userid', help='Id of the user') + self.parser.add_argument('-P', '--password', help='New password') + + def expand(self, id, cmd, args): + if not cmd.userid: + return None + + if not cmd.password: + stdoutln("Password (-P) not specified") + return None + + return ['acc --user %s --scheme basic --secret :%s' % (cmd.userid, cmd.password)] + + +class Useradd(Macro): + """Creates a new user account.""" + + def name(self): + return "useradd" + + def description(self): + return "Create a new user account" + + def add_parser_args(self): + self.parser.add_argument('login', help='User login') + self.parser.add_argument('-P', '--password', help='Password') + self.parser.add_argument('--cred', help='List of comma-separated credentials in format "(email|tel):value1,(email|tel):value2,..."') + self.parser.add_argument('--name', help='Public name of the user') + self.parser.add_argument('--comment', help='Private comment') + self.parser.add_argument('--note', help='Public description') + self.parser.add_argument('--trusted', help='Add/remove trusted marker: verified, staff, danger') + self.parser.add_argument('--tags', help='Comma-separated list of tags') + self.parser.add_argument('--avatar', help='Path to avatar file') + self.parser.add_argument('--auth', help='Default auth acs') + self.parser.add_argument('--anon', help='Default anon acs') + + def expand(self, id, cmd, args): + if not cmd.login: + return None + if not cmd.password: + stdoutln("Password --password must be specified") + return None + if not cmd.cred: + stdoutln("Must specify at least one credential: --cred.") + return None + varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp' + new_cmd = '.must ' + varname + ' acc --scheme basic --secret="%s:%s" --cred="%s"' % (cmd.login, cmd.password, cmd.cred) + if cmd.name: + new_cmd += ' --fn="%s"' % cmd.name + if cmd.comment: + new_cmd += ' --private="%s"' % cmd.comment + if cmd.note is not None: + set_cmd += ' --note="%s"' % cmd.note + if cmd.trusted is not None: + set_cmd += ' --trusted="%s" --as_root' % cmd.trusted + if cmd.tags: + new_cmd += ' --tags="%s"' % cmd.tags + if cmd.avatar: + new_cmd += ' --photo="%s"' % cmd.avatar + if cmd.auth: + new_cmd += ' --auth="%s"' % cmd.auth + if cmd.anon: + new_cmd += ' --anon="%s"' % cmd.anon + return [new_cmd] + + +class Chacs(Macro): + """Modifies default acs (permissions) on a user account.""" + + def name(self): + return "chacs" + + def description(self): + return "Change default permissions/acs for a user (requires root privileges)" + + def add_parser_args(self): + self.parser.add_argument('userid', help='User id') + self.parser.add_argument('--auth', help='New auth acs value') + self.parser.add_argument('--anon', help='New anon acs value') + + def expand(self, id, cmd, args): + if not cmd.userid: + return None + if not cmd.auth and not cmd.anon: + stdoutln('Must specify at least either of --auth, --anon') + return None + set_cmd = '.must set me' + if cmd.auth: + set_cmd += ' --auth=%s' % cmd.auth + if cmd.anon: + set_cmd += ' --anon=%s' % cmd.anon + old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else '' + return ['.use --user %s' % cmd.userid, + '.must sub me', + set_cmd, + '.must leave me', + '.use --user "%s"' % old_user] + +class Userdel(Macro): + """Deletes a user account.""" + + def name(self): + return "userdel" + + def description(self): + return "Delete user account (requires root privileges)" + + def add_parser_args(self): + self.parser.add_argument('userid', help='User id') + self.parser.add_argument('--hard', action='store_true', help='Hard delete') + + def expand(self, id, cmd, args): + if not cmd.userid: + return None + del_cmd = 'del user --user %s --as_root' % cmd.userid + if cmd.hard: + del_cmd += ' --hard' + return [del_cmd] + + +class Chcred(Macro): + """Adds, deletes or validates credentials for a user account.""" + + def name(self): + return "chcred" + + def description(self): + return "Add/delete/validate credentials (requires root privileges)" + + def add_parser_args(self): + self.parser.add_argument('userid', help='User id') + self.parser.add_argument('cred', help='Affected credential in formt method:value, e.g. email: abc@example.com, tel:17771112233') + self.parser.add_argument('--add', action='store_true', help='Add credential') + self.parser.add_argument('--rm', action='store_true', help='Delete credential') + self.parser.add_argument('--validate', action='store_true', help='Validate credential') + + def expand(self, id, cmd, args): + if not cmd.userid: + return None + if not cmd.cred: + stdoutln('Must specify cred') + return None + + num_actions = (1 if cmd.add else 0) + (1 if cmd.rm else 0) + (1 if cmd.validate else 0) + if num_actions == 0 or num_actions > 1: + stdoutln('Must specify exactly one action: --add, --rm, --validate') + return None + if cmd.add: + cred_cmd = '.must set me --cred %s' % cmd.cred + if cmd.rm: + cred_cmd = '.must del --topic me --cred %s cred' % cmd.cred + + old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else '' + return ['.use --user %s' % cmd.userid, + '.must sub me', + cred_cmd, + '.must leave me', + '.use --user "%s"' % old_user] + + +class Thecard(Macro): + """Prints user's theCard.""" + + def name(self): + return "thecard" + + def description(self): + return "Print theCard for a user (requires root privileges)" + + def add_parser_args(self): + self.parser.add_argument('userid', help='User id') + self.parser.add_argument('--what', choices=['desc', 'cred'], required=True, help='Type of data to print (desc - public/private data, cred - list of credentials.') + + def expand(self, id, cmd, args): + if not cmd.userid: + return None + + varname = cmd.varname if hasattr(cmd, 'varname') and cmd.varname else '$temp' + old_user = tn_globals.DefaultUser if tn_globals.DefaultUser else '' + return ['.use --user %s' % cmd.userid, + '.must sub me', + '.must %s get me --%s' % (varname, cmd.what), + '.must leave me', + '.use --user "%s"' % old_user, + '.log %s' % varname] + + +def parse_macro(parts): + """Attempts to find a parser for the provided sequence of tokens.""" + global Macros + # parts[0] is the command. + macro = Macros.get(parts[0]) + if not macro: + return None + return macro.parser + + +Macros = {x.name(): x for x in [Usermod(), Resolve(), Passwd(), Useradd(), Chacs(), Userdel(), Chcred(), Thecard()]} diff --git a/tn-cli/requirements.txt b/tn-cli/requirements.txt index e69de29..54e95a6 100644 --- a/tn-cli/requirements.txt +++ b/tn-cli/requirements.txt @@ -0,0 +1,7 @@ +futures>=3.2.0; python_version<'3' +grpcio>=1.40.0 +Pillow>=5.4.1 +requests>=2.21.0 +tinode-grpc>=0.22.0 +prompt_toolkit>=2.0.10 +importlib-metadata>=1.0; python_version<'3.8' diff --git a/tn-cli/sample-macro-script.txt b/tn-cli/sample-macro-script.txt index e69de29..2aabc24 100644 --- a/tn-cli/sample-macro-script.txt +++ b/tn-cli/sample-macro-script.txt @@ -0,0 +1,48 @@ +# This script shows what can be done with tn-cli macros. Run it as +# +# python tn-cli.py --no-login < sample-macro-script.txt +# +# The script performs the following: +# +# - Creates a new user Test User with useradd +# - Logs in as Xena (user has root privileges) +# - Changes Test User's name to 'Test User 1' +# - Modifies Test User's default anon acs to JR with chacs +# - Sets Test User's password to test456 +# - Deletes Test User with userdel +# - Uses resolve to locate user Alice +# - Subscribes to Alice +# - Sends a message to Alice + +# Create user 'Test User' +# .must directive ensures the command succeeds (in case of failure, the script execution stops. +# $user is the variable that will hold the result of the command execution. +.must $user useradd --name 'Test User' --password test123 --comment test0 \ + --cred email:test@example.com --avatar ./test-128.jpg --tags test,test-user \ + --auth=JRWPA --anon=JW test + +# Login as xena. +.must $xena login --scheme=basic --secret=xena:xena123 + +# Change test's public name. +.must usermod $user.params[user] --name 'Test User 1' + +# Change test's anon acs. +.must chacs $user.params[user] --anon=JR + +# Set test's password to test456. +passwd $user.params[user] -P test456 + +# Delete test user. +.must userdel $user.params[user] --hard + +# Find Alice. +.must $alice resolve alice + +# Subscribe to Alice +.must sub $alice.sub[0].topic + +# Send a plain text message to Alice (async) +pub $alice.sub[0].topic 'Hello, Alice' + +.sleep 2000 diff --git a/tn-cli/sample-script.txt b/tn-cli/sample-script.txt index e69de29..77f26ab 100644 --- a/tn-cli/sample-script.txt +++ b/tn-cli/sample-script.txt @@ -0,0 +1,78 @@ +# This script shows what can be done with tn-cli. Run it as +# +# python tn-cli.py --no-login < sample-script.txt +# +# The script performs the following: +# +# - Creates a new user Test User +# - Uses 'fnd' topic to locate user Alice +# - Subscribes Test User to Alice +# - Sends typing notification to Alice +# - Sends a message to Alice +# - Creates a group topic Test Group +# - Adds Alice to the Test Group +# - Sends a message with a drafty-formatted form to the Test Group +# - Deletes Test Group +# - Deletes Test User + +# Create user 'Test User' + +.must $user acc --scheme=basic --secret=test:test123 \ + --fn='Test User' --photo=./test-128.jpg --tags=test,test-user --do-login \ + --auth=JRWPA --anon=JW \ + --cred=email:test@example.com + +# Print out user's auth token. +# Params is a map, thus must be addressed as params[x] instead of params.x +.log $user.params[token] + +# Login and confirm credentials with a dummy response +.must login --scheme=token --secret=$user.params[token] --cred=email::123456 + +# Alternatively just login with an existing user. +# .must $user login bob:bob123 + +# Find Alice +.must sub fnd +.must set fnd --public=email:alice@example.com +.must $meta get fnd --sub + +# Print out Alice's UID. +.log $meta.sub[0].topic + +# Subscribe to Alice +.must sub $meta.sub[0].topic + +# Send typing notification to Alice (async) +note $meta.sub[0].topic + +# Send a plain text message to Alice (async) +pub $meta.sub[0].topic 'Hello, Alice' + +# Create a new group topic Test Group +.must $group sub new --fn='Test Group' --tags=test,test-group \ + --auth=JRWPA --anon=JR + +# Add Alice to the new topic. +.must set $group.topic --user=$meta.sub[0].topic + +# Publish a drafty-formatted form to the new topic. +pub $group.topic --drafty='{"txt": "What’s your favorite color?red green none",\ + "fmt": [ {"at": 0, "len": 42, "tp": "FM"}, {"at": 0, "len": 27, "tp": "ST"},\ + {"at": 27, "len": 14, "tp": "RW"}, {"at": 27, "len": 3, "key": 0}, {"at": 31, "len": 5, "key": 1},\ + {"at": 37, "len": 4, "key": 2} ], "ent": [ {"tp": "BN", "data": {"name": "red", "act": "pub", "val": 3840}},\ + {"tp": "BN", "data": {"name": "green", "act": "pub", "val": 240}},\ + {"tp": "BN", "data": {"name": "none", "act": "pub"}}]}' + + +# Wait 2 seconds before cleaning up. +.sleep 2000 + +# Delete Test Group. +.await del topic --topic=$group.topic --hard + +# Delete Test User +.await del user --hard + +# Wait for more messages before exiting. +.sleep 1000 diff --git a/tn-cli/test-128.jpg b/tn-cli/test-128.jpg index e69de29..3b25b0e 100644 Binary files a/tn-cli/test-128.jpg and b/tn-cli/test-128.jpg differ diff --git a/tn-cli/tn-cli.py b/tn-cli/tn-cli.py index e69de29..573a67b 100644 --- a/tn-cli/tn-cli.py +++ b/tn-cli/tn-cli.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +# coding=utf-8 + +"""Python implementation of Tinode command line client using gRPC.""" + +from __future__ import print_function + +import argparse +import os +import platform +import sys + +try: + from importlib.metadata import version +except ImportError: + # Fallback for Python < 3.8 + from importlib_metadata import version + +import tn_globals +from tn_globals import printout +from client import run, read_cookie +from commands import set_macros_module + +APP_NAME = "tn-cli" +APP_VERSION = "3.1.0" # format: 1.9.0b1 +LIB_VERSION = version("tinode_grpc") +GRPC_VERSION = version("grpcio") + +# This is needed for gRPC SSL to work correctly. +os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" + + +# Setup crash handler: close input reader otherwise a crash +# makes terminal session unusable. +def exception_hook(type, value, traceBack): + if tn_globals.InputThread != None: + tn_globals.InputThread.join(0.3) +sys.excepthook = exception_hook + + +# Enable the following variables for debugging. +# os.environ["GRPC_TRACE"] = "all" +# os.environ["GRPC_VERBOSITY"] = "INFO" + + +if __name__ == '__main__': + """Parse command-line arguments. Extract host name and authentication scheme, if one is provided""" + version_str = APP_VERSION + "/" + LIB_VERSION + "; gRPC/" + GRPC_VERSION + "; Python " + platform.python_version() + purpose = "Tinode command line client. Version " + version_str + "." + + parser = argparse.ArgumentParser(description=purpose) + parser.add_argument('--host', default='localhost:16060', help='address of Tinode gRPC server') + parser.add_argument('--web-host', default='localhost:6060', help='address of Tinode web server (for file uploads)') + parser.add_argument('--ssl', action='store_true', help='connect to server over secure connection') + parser.add_argument('--ssl-host', help='SSL host name to use instead of default (useful for connecting to localhost)') + parser.add_argument('--login-basic', help='login using basic authentication username:password') + parser.add_argument('--login-token', help='login using token authentication') + parser.add_argument('--login-cookie', action='store_true', help='read token from cookie file and use it for authentication') + parser.add_argument('--no-login', action='store_true', help='do not login even if cookie file is present; default in non-interactive (scripted) mode') + parser.add_argument('--no-cookie', action='store_true', help='do not save login cookie; default in non-interactive (scripted) mode') + parser.add_argument('--api-key', default='AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K', help='API key for file uploads') + parser.add_argument('--load-macros', default='./macros.py', help='path to macro module to load') + parser.add_argument('--version', action='store_true', help='print version') + parser.add_argument('--verbose', action='store_true', help='log full JSON representation of all messages') + parser.add_argument('--background', action='store_const', const=True, help='start interactive sessionin background (non-intractive is always in background)') + + args = parser.parse_args() + + if args.version: + printout(version_str) + exit() + + if args.verbose: + tn_globals.Verbose = True + + printout(purpose) + printout("Secure server" if args.ssl else "Server", "at '"+args.host+"'", + "SNI="+args.ssl_host if args.ssl_host else "") + + schema = None + secret = None + + if not args.no_login: + if args.login_token: + """Use token to login""" + schema = 'token' + secret = args.login_token.encode('ascii') + printout("Logging in with token", args.login_token) + + elif args.login_basic: + """Use username:password""" + schema = 'basic' + secret = args.login_basic + printout("Logging in with login:password", args.login_basic) + + elif tn_globals.IsInteractive: + """Interactive mode only: try reading the cookie file""" + printout("Logging in with cookie file") + schema = 'token' + secret = read_cookie() + if not secret: + schema = None + + # Attempt to load the macro file if available. + macros = None + if args.load_macros: + import importlib + macros = importlib.import_module('macros', args.load_macros) if args.load_macros else None + set_macros_module(macros) + + # Check if background session is specified explicitly. If not set it to + # True for non-interactive sessions. + if args.background is None and not tn_globals.IsInteractive: + args.background = True + + sys.exit(run(args, schema, secret)) diff --git a/tn-cli/tn_globals.py b/tn-cli/tn_globals.py index e69de29..aa8dc6a 100644 --- a/tn-cli/tn_globals.py +++ b/tn-cli/tn_globals.py @@ -0,0 +1,103 @@ +"""Global objects for Tinode command line client.""" +# To make print() compatible between p2 and p3 +from __future__ import print_function + +import json +import sys +from collections import deque +from google.protobuf.json_format import MessageToDict +try: + import Queue as queue +except ImportError: + import queue + +if sys.version_info[0] >= 3: + # for compatibility with python2 + unicode = str + +# Dictionary wich contains lambdas to be executed when server {ctrl} response is received. +OnCompletion = {} + +# Outstanding request for a synchronous message. +WaitingFor = None + +# Last obtained authentication token +AuthToken = '' + +# IO queues and a thread for asynchronous input/output +#InputQueue = queue.Queue() +InputQueue = deque() +OutputQueue = queue.Queue() +InputThread = None + +# Detect if the tn-cli is running interactively or being piped. +IsInteractive = sys.stdin.isatty() +Prompt = None + +# Default values for user and topic +DefaultUser = None +DefaultTopic = None + +# Variables: results of command execution +Variables = {} + +# Connection to the server +Connection = None + +# Flag to enable extended logging. Useful for debugging. +Verbose = False + +# Print prompts in interactive mode only. +def printout(*args): + if IsInteractive: + print(*args) + +def printerr(*args): + text = "" + for a in args: + text = text + str(a) + " " + # Strip just the spaces here, don't strip the newline or tabs. + text = text.strip(" ") + if text: + sys.stderr.write(text + "\n") + +# Support for asynchronous input-output to/from stdin/stdout + +# Stdout asynchronously writes to sys.stdout +def stdout(*args): + text = "" + for a in args: + text = text + str(a) + " " + # Strip just the spaces here, don't strip the newline or tabs. + text = text.strip(" ") + if text: + OutputQueue.put(text) + +# Stdoutln asynchronously writes to sys.stdout and adds a new line to input. +def stdoutln(*args): + args = args + ("\n",) + stdout(*args) + +# Shorten long strings for logging. +def clip_long_string(obj): + if isinstance(obj, str) or isinstance(obj, unicode): + if len(obj) > 64: + return '<' + str(len(obj)) + ' bytes: ' + obj[:12] + '...' + obj[-12:] + '>' + return obj + elif isinstance(obj, (list, tuple)): + return [clip_long_string(item) for item in obj] + elif isinstance(obj, dict): + return dict((key, clip_long_string(val)) for key, val in obj.items()) + else: + return obj + +# Convert protobuff message to json. Shorten very long strings. +def to_json(msg): + if not msg: + return 'null' + try: + return json.dumps(clip_long_string(MessageToDict(msg))) + except Exception as err: + stdoutln("Exception: {}".format(err)) + + return 'exception' diff --git a/tn-cli/utils.py b/tn-cli/utils.py index e69de29..579658a 100644 --- a/tn-cli/utils.py +++ b/tn-cli/utils.py @@ -0,0 +1,187 @@ +"""Utility functions for tn-cli.""" + +from __future__ import print_function + +import base64 +import json +from PIL import Image +try: + from io import BytesIO as memory_io +except ImportError: + from cStringIO import StringIO as memory_io +import mimetypes +import os + +from tn_globals import stdoutln + +# Maximum in-band (included directly into the message) attachment size which fits into +# a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead. +# This is size of an object *before* base64 encoding is applied. +MAX_INBAND_ATTACHMENT_SIZE = 195840 + +# Absolute maximum attachment size to be used with the server = 8MB. +MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23 + +# Maximum allowed linear dimension of an inline image in pixels. +MAX_IMAGE_DIM = 768 + +# String used as a delete marker. I.e. when a value needs to be deleted, use this string +DELETE_MARKER = 'DEL!' + +# Unicode DEL character used internally by Tinode when a value needs to be deleted. +TINODE_DEL = '␡' + + +# Python is retarded. +class dotdict(dict): + """dot.notation access to dictionary attributes""" + __getattr__ = dict.get + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + +# Pack name, description, and avatar into a theCard. +def makeTheCard(fn, note, photofile): + card = None + + if (fn != None and fn.strip() != "") or photofile != None or note != None: + card = {} + if fn != None: + fn = fn.strip() + card['fn'] = TINODE_DEL if fn == DELETE_MARKER or fn == '' else fn + + if note != None: + note = note.strip() + card['note'] = TINODE_DEL if note == DELETE_MARKER or note == '' else note + + if photofile != None: + if photofile == '' or photofile == DELETE_MARKER: + # Delete the avatar. + card['photo'] = { + 'data': TINODE_DEL + } + else: + try: + f = open(photofile, 'rb') + # File extension is used as a file type + mimetype = mimetypes.guess_type(photofile) + if mimetype[0]: + mimetype = mimetype[0].split("/")[1] + else: + mimetype = 'jpeg' + data = base64.b64encode(f.read()) + # python3 fix. + if type(data) is not str: + data = data.decode() + card['photo'] = { + 'data': data, + 'type': mimetype + } + f.close() + except IOError as err: + stdoutln("Error opening '" + photofile + "':", err) + + return card + + +# Create drafty representation of a message with an inline image. +def inline_image(filename): + try: + im = Image.open(filename, 'r') + width = im.width + height = im.height + format = im.format if im.format else "JPEG" + if width > MAX_IMAGE_DIM or height > MAX_IMAGE_DIM: + # Scale the image + scale = min(min(width, MAX_IMAGE_DIM) / width, min(height, MAX_IMAGE_DIM) / height) + width = int(width * scale) + height = int(height * scale) + resized = im.resize((width, height)) + im.close() + im = resized + + mimetype = 'image/' + format.lower() + bitbuffer = memory_io() + im.save(bitbuffer, format=format) + data = base64.b64encode(bitbuffer.getvalue()) + + # python3 fix. + if type(data) is not str: + data = data.decode() + + result = { + 'txt': ' ', + 'fmt': [{'len': 1}], + 'ent': [{'tp': 'IM', 'data': + {'val': data, 'mime': mimetype, 'width': width, 'height': height, + 'name': os.path.basename(filename)}}] + } + im.close() + return result + except IOError as err: + stdoutln("Failed processing image '" + filename + "':", err) + return None + + +# Create a drafty message with an *in-band* attachment. +def attachment(filename): + try: + f = open(filename, 'rb') + # Try to guess the mime type. + mimetype = mimetypes.guess_type(filename)[0] + data = base64.b64encode(f.read()) + # python3 fix. + if type(data) is not str: + data = data.decode() + result = { + 'fmt': [{'at': -1}], + 'ent': [{'tp': 'EX', 'data':{ + 'val': data, 'mime': mimetype, 'name':os.path.basename(filename) + }}] + } + f.close() + return result + except IOError as err: + stdoutln("Error processing attachment '" + filename + "':", err) + return None + + +# encode_to_bytes converts the 'src' to a byte array. +# An object/dictionary is first converted to json string then it's converted to bytes. +# A string is directly converted to bytes. +def encode_to_bytes(src): + if src == None: + return None + if isinstance(src, str): + return ('"' + src + '"').encode() + return json.dumps(src).encode('utf-8') + + +# Parse credentials +def parse_cred(cred): + result = None + if cred != None: + result = [] + for c in cred.split(","): + parts = c.split(":") + from tinode_grpc import pb + result.append(pb.ClientCred(method=parts[0] if len(parts) > 0 else None, + value=parts[1] if len(parts) > 1 else None, + response=parts[2] if len(parts) > 2 else None)) + + return result + + +# Parse trusted values: [staff,rm-verified]. +def parse_trusted(trusted): + result = None + if trusted != None: + result = {} + for t in trusted.split(","): + t = t.strip() + if t.startswith("rm-"): + result[t[3:]] = False + else: + result[t] = True + + return result