chore: seed migration objects batch 20
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 7.2 KiB |
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,465 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/auth"
|
||||
_ "github.com/tinode/chat/server/auth/basic"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func genDb(data *Data, p2pDel bool, tenantID types.TenantID) {
|
||||
var err error
|
||||
var botAccount string
|
||||
|
||||
if len(data.Users) == 0 {
|
||||
log.Println("No data provided, stopping")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Add authentication record
|
||||
authHandler := store.Store.GetAuthHandler("basic")
|
||||
authHandler.Init([]byte(`{"add_to_tags": true}`), "basic")
|
||||
|
||||
nameIndex := make(map[string]string, len(data.Users))
|
||||
|
||||
log.Println("Generating users...")
|
||||
|
||||
for _, uu := range data.Users {
|
||||
state, err := types.NewObjState(uu.State)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
user := types.User{
|
||||
TenantID: tenantID,
|
||||
State: state,
|
||||
Access: types.DefaultAccess{
|
||||
Auth: types.ModeCAuth,
|
||||
Anon: types.ModeNone,
|
||||
},
|
||||
Tags: uu.Tags,
|
||||
Public: parsePublic(&uu.Public, data.datapath),
|
||||
}
|
||||
if !uu.Trusted.IsZero() {
|
||||
user.Trusted = uu.Trusted
|
||||
}
|
||||
user.CreatedAt = getCreatedTime(uu.CreatedAt)
|
||||
|
||||
user.Tags = append(user.Tags, "basic:"+uu.Username)
|
||||
if uu.Email != "" {
|
||||
user.Tags = append(user.Tags, "email:"+uu.Email)
|
||||
}
|
||||
if uu.Tel != "" {
|
||||
user.Tags = append(user.Tags, "tel:"+uu.Tel)
|
||||
}
|
||||
|
||||
// store.Users.Create will subscribe user to !me topic but won't create a !me topic
|
||||
if _, err := store.Users.Create(tenantID, &user, uu.Private); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Save credentials: email and phone number as if they were confirmed.
|
||||
if uu.Email != "" {
|
||||
if _, err := store.Users.UpsertCred(tenantID, &types.Credential{
|
||||
User: user.Id,
|
||||
Method: "email",
|
||||
Value: uu.Email,
|
||||
Done: true,
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if uu.Tel != "" {
|
||||
if _, err := store.Users.UpsertCred(tenantID, &types.Credential{
|
||||
User: user.Id,
|
||||
Method: "tel",
|
||||
Value: uu.Tel,
|
||||
Done: true,
|
||||
}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
authLevel := auth.LevelAuth
|
||||
if uu.AuthLevel != "" {
|
||||
authLevel = auth.ParseAuthLevel(uu.AuthLevel)
|
||||
if authLevel == auth.LevelNone {
|
||||
log.Fatal("Unknown authLevel", uu.AuthLevel)
|
||||
}
|
||||
}
|
||||
// Add authentication record
|
||||
authHandler := store.Store.GetAuthHandler("basic")
|
||||
passwd := uu.Password
|
||||
if passwd == "(random)" {
|
||||
// Generate random password
|
||||
passwd = getPassword(8)
|
||||
botAccount = uu.Username
|
||||
}
|
||||
if _, err := authHandler.AddRecord(auth.AuthContext{TenantID: tenantID},
|
||||
&auth.Rec{TenantID: tenantID, Uid: user.Uid(), AuthLevel: authLevel},
|
||||
[]byte(uu.Username+":"+passwd)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
nameIndex[uu.Username] = user.Id
|
||||
|
||||
// Add address book as fnd.private
|
||||
if len(uu.AddressBook) > 0 {
|
||||
if err := store.Subs.Update(tenantID, user.Uid().FndName(), user.Uid(),
|
||||
map[string]any{"Private": strings.Join(uu.AddressBook, ",")}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("usr;" + uu.Username + ";" + user.Uid().UserId() + ";" + passwd)
|
||||
}
|
||||
|
||||
if botAccount == "" && len(data.Users) > 0 {
|
||||
botAccount = data.Users[0].Username
|
||||
}
|
||||
|
||||
log.Println("Generating group topics...")
|
||||
|
||||
for _, gt := range data.Grouptopics {
|
||||
name := genTopicName()
|
||||
nameIndex[gt.Name] = name
|
||||
|
||||
accessAuth := types.ModeCPublic
|
||||
if gt.Access.Auth != "" {
|
||||
if err := accessAuth.UnmarshalText([]byte(gt.Access.Auth)); err != nil {
|
||||
log.Fatal("Invalid Auth access mode", gt.Access.Auth, err)
|
||||
}
|
||||
}
|
||||
accessAnon := types.ModeCReadOnly
|
||||
if gt.Access.Anon != "" {
|
||||
if err := accessAnon.UnmarshalText([]byte(gt.Access.Anon)); err != nil {
|
||||
log.Fatal("Invalid Anon access mode", gt.Access.Anon, err)
|
||||
}
|
||||
}
|
||||
topic := &types.Topic{
|
||||
ObjHeader: types.ObjHeader{Id: name},
|
||||
TenantID: tenantID,
|
||||
Access: types.DefaultAccess{
|
||||
Auth: accessAuth,
|
||||
Anon: accessAnon,
|
||||
},
|
||||
UseBt: gt.Channel,
|
||||
Tags: gt.Tags,
|
||||
Public: parsePublic(>.Public, data.datapath),
|
||||
}
|
||||
if !gt.Trusted.IsZero() {
|
||||
topic.Trusted = gt.Trusted
|
||||
}
|
||||
var owner types.Uid
|
||||
if gt.Owner != "" {
|
||||
owner = types.ParseUid(nameIndex[gt.Owner])
|
||||
if owner.IsZero() {
|
||||
log.Fatal("Invalid owner", gt.Owner, "for topic", gt.Name)
|
||||
}
|
||||
topic.GiveAccess(owner, types.ModeCFull, types.ModeCFull)
|
||||
}
|
||||
topic.CreatedAt = getCreatedTime(gt.CreatedAt)
|
||||
|
||||
if err = store.Topics.Create(tenantID, topic, owner, gt.OwnerPrivate); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("grp;" + gt.Name + ";" + name)
|
||||
}
|
||||
|
||||
log.Println("Generating P2P subscriptions...")
|
||||
|
||||
for i, ss := range data.P2psubs {
|
||||
if ss.Users[0].Name < ss.Users[1].Name {
|
||||
ss.pair = ss.Users[0].Name + ":" + ss.Users[1].Name
|
||||
} else {
|
||||
ss.pair = ss.Users[1].Name + ":" + ss.Users[0].Name
|
||||
}
|
||||
|
||||
uid1 := types.ParseUid(nameIndex[ss.Users[0].Name])
|
||||
uid2 := types.ParseUid(nameIndex[ss.Users[1].Name])
|
||||
topic := uid1.P2PName(uid2)
|
||||
created := getCreatedTime(ss.CreatedAt)
|
||||
|
||||
// Assign default access mode
|
||||
defaultMode := types.ModeCP2P
|
||||
if p2pDel {
|
||||
defaultMode = types.ModeCP2PD
|
||||
}
|
||||
s0want := defaultMode
|
||||
s0given := defaultMode
|
||||
s1want := defaultMode
|
||||
s1given := defaultMode
|
||||
|
||||
// Check of non-default access mode was provided
|
||||
if ss.Users[0].Want != "" {
|
||||
if err := s0want.UnmarshalText([]byte(ss.Users[0].Want)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ss.Users[0].Have != "" {
|
||||
if err := s0given.UnmarshalText([]byte(ss.Users[0].Have)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ss.Users[1].Want != "" {
|
||||
if err := s1want.UnmarshalText([]byte(ss.Users[1].Want)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ss.Users[1].Have != "" {
|
||||
if err := s1given.UnmarshalText([]byte(ss.Users[1].Have)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err := store.Topics.CreateP2P(tenantID,
|
||||
&types.Subscription{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: created},
|
||||
TenantID: tenantID,
|
||||
User: uid1.String(),
|
||||
Topic: topic,
|
||||
ModeWant: s0want,
|
||||
ModeGiven: s0given,
|
||||
Private: ss.Users[0].Private},
|
||||
&types.Subscription{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: created},
|
||||
TenantID: tenantID,
|
||||
User: uid2.String(),
|
||||
Topic: topic,
|
||||
ModeWant: s1want,
|
||||
ModeGiven: s1given,
|
||||
Private: ss.Users[1].Private})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
data.P2psubs[i].pair = ss.pair
|
||||
nameIndex[ss.pair] = topic
|
||||
fmt.Println("p2p;" + ss.pair + ";" + topic)
|
||||
}
|
||||
|
||||
log.Println("Generating group subscriptions...")
|
||||
|
||||
for _, ss := range data.Groupsubs {
|
||||
var want, given types.AccessMode
|
||||
if ss.AsChan {
|
||||
want = types.ModeCChnReader
|
||||
given = types.ModeCChnReader
|
||||
} else {
|
||||
want = types.ModeCPublic
|
||||
given = types.ModeCPublic
|
||||
}
|
||||
if ss.Want != "" {
|
||||
if err := want.UnmarshalText([]byte(ss.Want)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ss.Have != "" {
|
||||
if err := given.UnmarshalText([]byte(ss.Have)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
tname := nameIndex[ss.Topic]
|
||||
if ss.AsChan {
|
||||
tname = types.GrpToChn(tname)
|
||||
}
|
||||
if err = store.Subs.Create(tenantID, &types.Subscription{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: getCreatedTime(ss.CreatedAt)},
|
||||
TenantID: tenantID,
|
||||
User: nameIndex[ss.User],
|
||||
Topic: tname,
|
||||
ModeWant: want,
|
||||
ModeGiven: given,
|
||||
Private: ss.Private}); err != nil {
|
||||
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
seqIds := map[string]int{}
|
||||
now := types.TimeNow().Add(-time.Minute * 10)
|
||||
|
||||
messageCount := len(data.Messages)
|
||||
if messageCount > 0 {
|
||||
log.Println("Inserting messages...")
|
||||
|
||||
if messageCount > 1 {
|
||||
// Shuffle messages
|
||||
rand.Shuffle(len(data.Messages), func(i, j int) {
|
||||
data.Messages[i], data.Messages[j] = data.Messages[j], data.Messages[i]
|
||||
})
|
||||
|
||||
// Starting 4 days ago.
|
||||
timestamp := now.Add(time.Hour * time.Duration(-24*4))
|
||||
toInsert := 96 // 96 is the maximum, otherwise messages may appear in the future
|
||||
// Initial maximum increment of the message sent time in milliseconds
|
||||
increment := 3600 * 1000
|
||||
subIdx := rand.Intn(len(data.Groupsubs) + len(data.P2psubs)*2)
|
||||
for i := range toInsert {
|
||||
// At least 20% of subsequent messages should come from the same user in the same topic.
|
||||
if rand.Intn(5) > 0 {
|
||||
subIdx = rand.Intn(len(data.Groupsubs) + len(data.P2psubs)*2)
|
||||
}
|
||||
|
||||
var topic string
|
||||
var from types.Uid
|
||||
if subIdx < len(data.Groupsubs) {
|
||||
if data.Groupsubs[subIdx].AsChan {
|
||||
// Channel readers should not have any published messages.
|
||||
continue
|
||||
}
|
||||
topic = nameIndex[data.Groupsubs[subIdx].Topic]
|
||||
from = types.ParseUid(nameIndex[data.Groupsubs[subIdx].User])
|
||||
} else {
|
||||
idx := (subIdx - len(data.Groupsubs)) / 2
|
||||
usr := (subIdx - len(data.Groupsubs)) % 2
|
||||
sub := data.P2psubs[idx]
|
||||
topic = nameIndex[sub.pair]
|
||||
from = types.ParseUid(nameIndex[sub.Users[usr].Name])
|
||||
}
|
||||
|
||||
seqIds[topic]++
|
||||
seqId := seqIds[topic]
|
||||
str := data.Messages[i%len(data.Messages)]
|
||||
// Max time between messages is 2 hours, averate - 1 hour, time is increasing as seqId increases
|
||||
timestamp = timestamp.Add(time.Microsecond * time.Duration(rand.Intn(increment)))
|
||||
if timestamp.After(now) {
|
||||
now = timestamp
|
||||
}
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: timestamp},
|
||||
TenantID: tenantID,
|
||||
SeqId: seqId,
|
||||
Topic: topic,
|
||||
From: from.String(),
|
||||
Content: str,
|
||||
}, nil, true); err != nil {
|
||||
log.Fatal("Failed to insert message: ", err)
|
||||
}
|
||||
|
||||
// New increment: remaining time until 'now' divided by the number of messages to be inserted,
|
||||
// then converted to milliseconds.
|
||||
increment = int(now.Sub(timestamp).Nanoseconds() / int64(toInsert-i) / 1000000)
|
||||
|
||||
// log.Printf("Msg.seq=%d at %v, topic='%s' from='%s'", msg.SeqId, msg.CreatedAt, topic, from.UserId())
|
||||
}
|
||||
} else {
|
||||
// Only one message is provided. Just insert it into every topic.
|
||||
now := time.Now().UTC().Add(-time.Minute).Round(time.Millisecond)
|
||||
|
||||
for _, gt := range data.Grouptopics {
|
||||
seqIds[nameIndex[gt.Name]] = 1
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: now},
|
||||
TenantID: tenantID,
|
||||
SeqId: 1,
|
||||
Topic: nameIndex[gt.Name],
|
||||
From: nameIndex[gt.Owner],
|
||||
Content: data.Messages[0],
|
||||
}, nil, true); err != nil {
|
||||
log.Fatal("Failed to insert message: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
usedp2p := make(map[string]bool)
|
||||
for i := 0; len(usedp2p) < len(data.P2psubs)/2; i++ {
|
||||
sub := data.P2psubs[i]
|
||||
if usedp2p[nameIndex[sub.pair]] {
|
||||
continue
|
||||
}
|
||||
usedp2p[nameIndex[sub.pair]] = true
|
||||
seqIds[nameIndex[sub.pair]] = 1
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: now},
|
||||
TenantID: tenantID,
|
||||
SeqId: 1,
|
||||
Topic: nameIndex[sub.pair],
|
||||
From: nameIndex[sub.Users[0].Name],
|
||||
Content: data.Messages[0],
|
||||
}, nil, true); err != nil {
|
||||
log.Fatal("Failed to insert message: ", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.Forms) != 0 {
|
||||
from := nameIndex[botAccount]
|
||||
log.Println("Inserting forms as ", botAccount, from)
|
||||
ts := now
|
||||
for _, form := range data.Forms {
|
||||
for _, sub := range data.P2psubs {
|
||||
ts = ts.Add(time.Second)
|
||||
seqIds[nameIndex[sub.pair]]++
|
||||
seqId := seqIds[nameIndex[sub.pair]]
|
||||
if sub.Users[0].Name == botAccount || sub.Users[1].Name == botAccount {
|
||||
if err, _ = store.Messages.Save(tenantID, &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: ts},
|
||||
TenantID: tenantID,
|
||||
SeqId: seqId,
|
||||
Topic: nameIndex[sub.pair],
|
||||
Head: types.KVMap{"mime": "text/x-drafty"},
|
||||
From: from,
|
||||
Content: form,
|
||||
}, nil, true); err != nil {
|
||||
log.Fatal("Failed to insert form: ", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Sample data processing completed.")
|
||||
}
|
||||
|
||||
// Go json cannot unmarshal Duration from a string, thus this hack.
|
||||
func getCreatedTime(delta string) time.Time {
|
||||
dd, err := time.ParseDuration(delta)
|
||||
if err != nil && delta != "" {
|
||||
log.Fatal("Invalid duration string", delta)
|
||||
}
|
||||
|
||||
return time.Now().UTC().Round(time.Millisecond).Add(dd)
|
||||
}
|
||||
|
||||
type photoStruct struct {
|
||||
Type string `json:"type" db:"type"`
|
||||
Data []byte `json:"data" db:"data"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Fn string `json:"fn" db:"fn"`
|
||||
Photo *photoStruct `json:"photo,omitempty" db:"photo"`
|
||||
}
|
||||
|
||||
// {"fn": "Alice Johnson", "photo": "alice-128.jpg"}
|
||||
func parsePublic(public *theCard, path string) *card {
|
||||
var photo *photoStruct
|
||||
var err error
|
||||
|
||||
if public.Fn == "" && public.Photo == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if fname := public.Photo; fname != "" {
|
||||
photo = &photoStruct{Type: public.Type}
|
||||
dir, _ := filepath.Split(fname)
|
||||
if dir == "" {
|
||||
dir = path
|
||||
}
|
||||
photo.Data, err = os.ReadFile(filepath.Join(dir, fname))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return &card{Fn: public.Fn, Photo: photo}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 5.2 KiB |
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 14 KiB |
@@ -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]
|
||||
```
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Code in this folder is licensed under Apache 2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()]}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 6.0 KiB |
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||