chore: seed migration objects batch 20

This commit is contained in:
dev
2026-10-18 13:16:45 +08:00
parent ff8b216cc0
commit 22a1e1f875
70 changed files with 15079 additions and 5 deletions
+4067
View File
File diff suppressed because it is too large Load Diff
+310
View File
@@ -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
}
+3386
View File
File diff suppressed because it is too large Load Diff
+1231
View File
File diff suppressed because it is too large Load Diff
+932
View File
@@ -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
}
+532
View File
@@ -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)
}
}
}
+553
View File
@@ -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{})
}
+278
View File
@@ -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{})
}
+5 -5
View File
@@ -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".
Symlink
+1
View File
@@ -0,0 +1 @@
./server/templ
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+54
View File
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 7.2 KiB

Regular → Executable
+28
View File
@@ -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
+385
View File
@@ -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"}}
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 6.7 KiB

+465
View File
@@ -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(&gt.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}
}
+104
View File
@@ -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)
+382
View File
@@ -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)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 5.2 KiB

+29
View File
@@ -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",
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 14 KiB

+201
View File
@@ -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]
```
+2
View File
@@ -0,0 +1,2 @@
Code in this folder is licensed under Apache 2.0
http://www.apache.org/licenses/LICENSE-2.0
+107
View File
@@ -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 `<command> -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
+283
View File
@@ -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
+800
View File
@@ -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 == <actual value of usr>
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>] <waitable_command> <params>
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 <command> -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))
+65
View File
@@ -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')
+340
View File
@@ -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()]}
+7
View File
@@ -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'
+48
View File
@@ -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
+78
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 6.0 KiB

+116
View File
@@ -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))
+103
View File
@@ -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'
+187
View File
@@ -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