chore: seed migration objects batch 20
This commit is contained in:
+4067
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1231
File diff suppressed because it is too large
Load Diff
+932
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user