chore: seed migration objects batch 20

This commit is contained in:
dev
2026-10-18 13:16:45 +08:00
parent ff8b216cc0
commit 22a1e1f875
70 changed files with 15079 additions and 5 deletions
+4067
View File
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
/******************************************************************************
* Description :
* Topic in a cluster which serves as a local representation of the master
* topic hosted at another node.
*****************************************************************************/
package main
import (
"net/http"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store/types"
)
func (t *Topic) runProxy(hub *Hub) {
killTimer := time.NewTimer(time.Hour)
killTimer.Stop()
for {
select {
case msg := <-t.reg:
// Request to add a connection to this topic
if t.isInactive() {
msg.sess.queueOut(ErrLockedReply(msg, types.TimeNow()))
} else if err := globals.cluster.routeToTopicMaster(ProxyReqJoin, msg, t.name, msg.sess); err != nil {
// Response (ctrl message) will be handled when it's received via the proxy channel.
logs.Warn.Printf("proxy topic[%s]: route join request from proxy to master failed - %s", t.name, err)
msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))
}
if msg.sess.inflightReqs != nil {
msg.sess.inflightReqs.Done()
}
case msg := <-t.unreg:
if !t.handleProxyLeaveRequest(msg, killTimer) {
sid := "nil"
if msg.sess != nil {
sid = msg.sess.sid
}
logs.Warn.Printf("proxy topic[%s]: failed to update proxy topic state for leave request - sid %s", t.name, sid)
msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))
}
if msg.init && msg.sess.inflightReqs != nil {
// If it's a client initiated request.
msg.sess.inflightReqs.Done()
}
case msg := <-t.clientMsg:
// Content message intended for broadcasting to recipients
if err := globals.cluster.routeToTopicMaster(ProxyReqBroadcast, msg, t.name, msg.sess); err != nil {
logs.Warn.Printf("topic proxy[%s]: route broadcast request from proxy to master failed - %s", t.name, err)
msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))
}
case msg := <-t.serverMsg:
if msg.Info != nil || msg.Pres != nil {
globals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess)
} else {
// FIXME: should something be done here?
logs.Err.Printf("ERROR!!! topic proxy[%s]: unexpected server-side message in proxy topic %s", t.name, msg.describe())
}
case msg := <-t.meta:
// Request to get/set topic metadata
if err := globals.cluster.routeToTopicMaster(ProxyReqMeta, msg, t.name, msg.sess); err != nil {
logs.Warn.Printf("proxy topic[%s]: route meta request from proxy to master failed - %s", t.name, err)
msg.sess.queueOut(ErrClusterUnreachableReply(msg, types.TimeNow()))
}
case upd := <-t.supd:
// Either an update to 'me' user agent from one of the sessions or
// background session comes to foreground.
req := ProxyReqMeUserAgent
tmpSess := &Session{tenantID: t.tenantID, userAgent: upd.userAgent}
if upd.sess != nil {
// Subscribed user may not match session user. Find out who is subscribed
pssd, ok := t.sessions[upd.sess]
if !ok {
logs.Warn.Printf("proxy topic[%s]: sess update request from detached session - sid %s", t.name, upd.sess.sid)
continue
}
req = ProxyReqBgSession
tmpSess.uid = pssd.uid
tmpSess.sid = upd.sess.sid
tmpSess.userAgent = upd.sess.userAgent
}
if err := globals.cluster.routeToTopicMaster(req, nil, t.name, tmpSess); err != nil {
logs.Warn.Printf("proxy topic[%s]: route sess update request from proxy to master failed - %s", t.name, err)
}
case msg := <-t.proxy:
t.proxyMasterResponse(msg, killTimer)
case sd := <-t.exit:
// Tell sessions to remove the topic
for s := range t.sessions {
s.detachSession(t.name)
}
if err := globals.cluster.topicProxyGone(t.tenantID, t.name); err != nil {
logs.Warn.Printf("proxy topic[%s] shutdown: failed to notify master - %s", t.name, err)
}
// Report completion back to sender, if 'done' is not nil.
if sd.done != nil {
sd.done <- true
}
return
case <-killTimer.C:
// Topic timeout
hub.unreg <- &topicUnreg{tenantID: t.tenantID, rcptTo: t.name}
}
}
}
// Takes a session leave request, forwards it to the topic master and
// modifies the local state accordingly.
// Returns whether the operation was successful.
func (t *Topic) handleProxyLeaveRequest(msg *ClientComMessage, killTimer *time.Timer) bool {
// Detach session from topic; session may continue to function.
var asUid types.Uid
if msg.init {
asUid = types.ParseUserId(msg.AsUser)
}
if asUid.IsZero() {
if pssd, ok := t.sessions[msg.sess]; ok {
asUid = pssd.uid
} else {
logs.Warn.Printf("proxy topic[%s]: leave request sent for unknown session", t.name)
return false
}
}
// Remove the session from the topic without waiting for a response from the master node
// because by the time the response arrives this session may be already gone from the session store
// and we won't be able to find and remove it by its sid.
pssd, result := t.remSession(msg.sess, asUid)
if result {
msg.sess.delSub(t.name)
}
if !msg.init {
// Explicitly specify the uid because the master multiplex session needs to know which
// of its multiple hosted sessions to delete.
msg.AsUser = asUid.UserId()
msg.Leave = &MsgClientLeave{}
msg.init = true
}
// Make sure we set the Original field if it's empty (e.g. when session is terminating altogether).
if msg.Original == "" {
if t.cat == types.TopicCatGrp && t.isChan {
// It's a channel topic. Original topic name depends the subscription type.
if result && pssd.isChanSub {
msg.Original = types.GrpToChn(t.xoriginal)
} else {
msg.Original = t.xoriginal
}
} else {
msg.Original = t.original(asUid)
}
}
if err := globals.cluster.routeToTopicMaster(ProxyReqLeave, msg, t.name, msg.sess); err != nil {
logs.Warn.Printf("proxy topic[%s]: route leave request from proxy to master failed - %s", t.name, err)
}
if len(t.sessions) == 0 {
// No more sessions attached. Start the countdown.
killTimer.Reset(idleProxyTopicTimeout)
}
return result
}
// proxyMasterResponse at proxy topic processes a master topic response to an earlier request.
func (t *Topic) proxyMasterResponse(msg *ClusterResp, killTimer *time.Timer) {
// Kills topic after a period of inactivity.
keepAlive := idleProxyTopicTimeout
if msg.SrvMsg.Pres != nil && msg.SrvMsg.Pres.What == "acs" && msg.SrvMsg.Pres.Acs != nil {
// If the server changed acs on this topic, update the internal state.
t.updateAcsFromPresMsg(msg.SrvMsg.Pres)
}
if msg.OrigSid == "*" {
// It is a broadcast.
switch {
case msg.SrvMsg.Pres != nil || msg.SrvMsg.Data != nil || msg.SrvMsg.Info != nil:
// Regular broadcast.
t.handleProxyBroadcast(msg.SrvMsg)
case msg.SrvMsg.Ctrl != nil:
// Ctrl broadcast. E.g. for user eviction.
t.proxyCtrlBroadcast(msg.SrvMsg)
default:
}
} else {
sess := globals.sessionStore.Get(msg.OrigSid)
if sess == nil {
logs.Warn.Printf("proxy topic[%s]: session %s not found; already terminated?", t.name, msg.OrigSid)
}
switch msg.OrigReqType {
case ProxyReqJoin:
if sess != nil && msg.SrvMsg.Ctrl != nil {
// TODO: do we need to let the master topic know that the subscription is not longer valid
// or is it already informed by the session when it terminated?
// Subscription result.
if msg.SrvMsg.Ctrl.Code < 300 {
sess.sessionStoreLock.Lock()
// Make sure the session isn't gone yet.
if session := globals.sessionStore.Get(msg.OrigSid); session != nil {
// Successful subscriptions.
t.addSession(session, msg.SrvMsg.uid, types.IsChannel(msg.SrvMsg.Ctrl.Topic))
session.addSub(t.name, &Subscription{
broadcast: t.clientMsg,
done: t.unreg,
meta: t.meta,
supd: t.supd,
})
}
sess.sessionStoreLock.Unlock()
killTimer.Stop()
} else if len(t.sessions) == 0 {
killTimer.Reset(keepAlive)
}
}
case ProxyReqBroadcast, ProxyReqMeta, ProxyReqCall:
// no processing
case ProxyReqLeave:
if msg.SrvMsg != nil && msg.SrvMsg.Ctrl != nil {
if msg.SrvMsg.Ctrl.Code < 300 {
if sess != nil {
t.remSession(sess, sess.uid)
}
}
// All sessions are gone. Start the kill timer.
if len(t.sessions) == 0 {
killTimer.Reset(keepAlive)
}
}
default:
logs.Err.Printf("proxy topic[%s] received response referencing unexpected request type %d",
t.name, msg.OrigReqType)
}
if sess != nil && !sess.queueOut(msg.SrvMsg) {
logs.Err.Printf("proxy topic[%s]: timeout in sending response - sid %s", t.name, sess.sid)
}
}
}
// handleProxyBroadcast broadcasts a Data, Info or Pres message to sessions attached to this proxy topic.
func (t *Topic) handleProxyBroadcast(msg *ServerComMessage) {
if t.isInactive() {
// Ignore broadcast - topic is paused or being deleted.
return
}
if msg.Data != nil {
t.lastID = msg.Data.SeqId
}
t.broadcastToSessions(msg)
}
// proxyCtrlBroadcast broadcasts a ctrl command to certain sessions attached to this proxy topic.
func (t *Topic) proxyCtrlBroadcast(msg *ServerComMessage) {
if msg.Ctrl.Code == http.StatusResetContent && msg.Ctrl.Text == "evicted" {
// We received a ctrl command for evicting a user.
if msg.uid.IsZero() {
logs.Err.Panicf("proxy topic[%s]: proxy received evict message with empty uid", t.name)
}
for sess := range t.sessions {
// Proxy topic may only have ordinary sessions. No multiplexing or proxy sessions here.
if _, removed := t.remSession(sess, msg.uid); removed {
sess.detachSession(t.name)
if sess.sid != msg.SkipSid {
sess.queueOut(msg)
}
}
}
}
}
// updateAcsFromPresMsg modifies user acs in Topic's perUser struct based on the data in `pres`.
func (t *Topic) updateAcsFromPresMsg(pres *MsgServerPres) {
uid := types.ParseUserId(pres.Src)
if uid.IsZero() {
if t.cat != types.TopicCatMe {
logs.Warn.Printf("proxy topic[%s]: received acs change for invalid user id '%s'", t.name, pres.Src)
}
return
}
// If t.perUser[uid] does not exist, pud is initialized with blanks, otherwise it gets existing values.
pud := t.perUser[uid]
dacs := pres.Acs
if err := pud.modeWant.ApplyMutation(dacs.Want); err != nil {
logs.Warn.Printf("proxy topic[%s]: could not process acs change - want: %s", t.name, err)
return
}
if err := pud.modeGiven.ApplyMutation(dacs.Given); err != nil {
logs.Warn.Printf("proxy topic[%s]: could not process acs change - given: %s", t.name, err)
return
}
// Update existing or add new.
t.perUser[uid] = pud
}
+3386
View File
File diff suppressed because it is too large Load Diff
+1231
View File
File diff suppressed because it is too large Load Diff
+932
View File
@@ -0,0 +1,932 @@
// Generic data manipulation utilities.
package main
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"path/filepath"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"maps"
"golang.org/x/crypto/acme/autocert"
)
// Tag with prefix:
// * prefix starts with an ASCII letter, contains ASCII letters, numbers, from 2 to 16 chars
// * tag body may contain Unicode letters and numbers, as well as the following symbols: +-.!?#@_
// Tag body can be up to maxTagLength (96) chars long.
var prefixedTagRegexp = regexp.MustCompile(`^([a-z]\w{1,15}):([-_+.!?#@\pL\pN]{1,96})$`)
// Generic tag: the same restrictions as tag body.
var tagRegexp = regexp.MustCompile(`^[-_+.!?#@\pL\pN]{1,96}$`)
const nullValue = "\u2421"
// Convert database ranges into wire protocol ranges.
func rangeDeserialize(in []types.Range) []MsgRange {
if len(in) == 0 {
return nil
}
out := make([]MsgRange, 0, len(in))
for _, r := range in {
out = append(out, MsgRange{LowId: r.Low, HiId: r.Hi})
}
return out
}
// Convert wire protocol ranges into database ranges.
func rangeSerialize(in []MsgRange) []types.Range {
if len(in) == 0 {
return nil
}
out := make([]types.Range, 0, len(in))
for _, r := range in {
out = append(out, types.Range{Low: r.LowId, Hi: r.HiId})
}
return out
}
// stringSliceDelta extracts the slices of added and removed strings from two slices:
//
// added := newSlice - (oldSlice & newSlice) -- present in new but missing in old
// removed := oldSlice - (oldSlice & newSlice) -- present in old but missing in new
// intersection := oldSlice & newSlice -- present in both old and new
func stringSliceDelta(rold, rnew []string) (added, removed, intersection []string) {
if len(rold) == 0 && len(rnew) == 0 {
return nil, nil, nil
}
if len(rold) == 0 {
return rnew, nil, nil
}
if len(rnew) == 0 {
return nil, rold, nil
}
sort.Strings(rold)
sort.Strings(rnew)
// Match old slice against the new slice and separate removed strings from added.
o, n := 0, 0
lold, lnew := len(rold), len(rnew)
for o < lold || n < lnew {
if o == lold || (n < lnew && rold[o] > rnew[n]) {
// Present in new, missing in old: added
added = append(added, rnew[n])
n++
} else if n == lnew || rold[o] < rnew[n] {
// Present in old, missing in new: removed
removed = append(removed, rold[o])
o++
} else {
// present in both
intersection = append(intersection, rold[o])
if o < lold {
o++
}
if n < lnew {
n++
}
}
}
return added, removed, intersection
}
// Process credentials for correctness: remove duplicate and unknown methods.
// In case of duplicate methods only the first one satisfying valueRequired is kept.
// If valueRequired is true, keep only those where Value is non-empty.
func normalizeCredentials(creds []MsgCredClient, valueRequired bool) []MsgCredClient {
if len(creds) == 0 {
return nil
}
index := make(map[string]*MsgCredClient)
for i := range creds {
c := &creds[i]
if _, ok := globals.validators[c.Method]; ok && (!valueRequired || c.Value != "") {
index[c.Method] = c
}
}
creds = make([]MsgCredClient, 0, len(index))
for _, c := range index {
creds = append(creds, *index[c.Method])
}
return creds
}
// Get a string slice with methods of credentials.
func credentialMethods(creds []MsgCredClient) []string {
out := make([]string, len(creds))
for i := range creds {
out[i] = creds[i].Method
}
return out
}
// Takes MsgClientGet query parameters, returns database query parameters
func msgOpts2storeOpts(req *MsgGetOpts) *types.QueryOpt {
var opts *types.QueryOpt
if req != nil {
opts = &types.QueryOpt{
User: types.ParseUserId(req.User),
Topic: req.Topic,
IfModifiedSince: req.IfModifiedSince,
Limit: req.Limit,
Since: req.SinceId,
Before: req.BeforeId,
IdRanges: rangeSerialize(req.IdRanges),
}
}
return opts
}
// Check if the interface contains a string with a single Unicode Del control character.
func isNullValue(i any) bool {
if str, ok := i.(string); ok {
return str == nullValue
}
return false
}
func decodeStoreError(err error, id string, ts time.Time, params map[string]any) *ServerComMessage {
return decodeStoreErrorExplicitTs(err, id, "", ts, ts, params)
}
func decodeStoreErrorExplicitTs(err error, id, topic string, serverTs, incomingReqTs time.Time,
params map[string]any) *ServerComMessage {
var errmsg *ServerComMessage
if err == nil {
errmsg = NoErrExplicitTs(id, topic, serverTs, incomingReqTs)
} else if storeErr, ok := err.(types.StoreError); !ok {
errmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs)
} else {
switch storeErr {
case types.ErrInternal:
errmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs)
case types.ErrMalformed:
errmsg = ErrMalformedExplicitTs(id, topic, serverTs, incomingReqTs)
case types.ErrFailed:
errmsg = ErrAuthFailed(id, topic, serverTs, incomingReqTs)
case types.ErrPermissionDenied:
errmsg = ErrPermissionDeniedExplicitTs(id, topic, serverTs, incomingReqTs)
case types.ErrDuplicate:
errmsg = ErrDuplicateCredential(id, topic, serverTs, incomingReqTs)
case types.ErrUnsupported:
errmsg = ErrNotImplemented(id, topic, serverTs, incomingReqTs)
case types.ErrExpired:
errmsg = ErrAuthFailed(id, topic, serverTs, incomingReqTs)
case types.ErrPolicy:
errmsg = ErrPolicyExplicitTs(id, topic, serverTs, incomingReqTs)
case types.ErrCredentials:
errmsg = InfoValidateCredentialsExplicitTs(id, serverTs, incomingReqTs)
case types.ErrUserNotFound:
errmsg = ErrUserNotFound(id, topic, serverTs, incomingReqTs)
case types.ErrTopicNotFound:
errmsg = ErrTopicNotFound(id, topic, serverTs, incomingReqTs)
case types.ErrNotFound:
errmsg = ErrNotFoundExplicitTs(id, topic, serverTs, incomingReqTs)
case types.ErrInvalidResponse:
errmsg = ErrInvalidResponse(id, topic, serverTs, incomingReqTs)
case types.ErrRedirected:
errmsg = InfoUseOther(id, topic, params["topic"].(string), serverTs, incomingReqTs)
default:
errmsg = ErrUnknownExplicitTs(id, topic, serverTs, incomingReqTs)
}
}
if params != nil {
errmsg.Ctrl.Params = params
}
return errmsg
}
// Helper function to select access mode for the given auth level
func selectAccessMode(authLvl auth.Level, anonMode, authMode, rootMode types.AccessMode) types.AccessMode {
switch authLvl {
case auth.LevelNone:
return types.ModeNone
case auth.LevelAnon:
return anonMode
case auth.LevelAuth:
return authMode
case auth.LevelRoot:
return rootMode
default:
return types.ModeNone
}
}
// Get default modeWant for the given topic category
func getDefaultAccess(cat types.TopicCat, authUser, isChan bool) types.AccessMode {
if !authUser {
return types.ModeNone
}
switch cat {
case types.TopicCatP2P:
return globals.typesModeCP2P
case types.TopicCatFnd:
return types.ModeNone
case types.TopicCatGrp:
if isChan {
return types.ModeCChnWriter
}
return types.ModeCPublic
case types.TopicCatMe:
return types.ModeCMeFnd
case types.TopicCatSlf:
return types.ModeCSelf
default:
panic("Unknown topic category")
}
}
// Parse topic access parameters
func parseTopicAccess(acs *MsgDefaultAcsMode, defAuth, defAnon types.AccessMode) (authMode, anonMode types.AccessMode,
err error) {
authMode, anonMode = defAuth, defAnon
if acs.Auth != "" {
err = authMode.UnmarshalText([]byte(acs.Auth))
}
if acs.Anon != "" {
err = anonMode.UnmarshalText([]byte(acs.Anon))
}
return
}
// Parse one component of a semantic version string.
func parseVersionPart(vers string) int {
end := strings.IndexFunc(vers, func(r rune) bool {
return !unicode.IsDigit(r)
})
t := 0
var err error
if end > 0 {
t, err = strconv.Atoi(vers[:end])
} else if len(vers) > 0 {
t, err = strconv.Atoi(vers)
}
if err != nil || t > 0x1fff || t <= 0 {
return 0
}
return t
}
// Parses semantic version string in the following formats:
//
// 1.2, 1.2abc, 1.2.3, 1.2.3-abc, v0.12.34-rc5
//
// Unparceable values are replaced with zeros.
func parseVersion(vers string) int {
var major, minor, patch int
// Maybe remove the optional "v" prefix.
vers = strings.TrimPrefix(vers, "v")
// We can handle 3 parts only.
parts := strings.SplitN(vers, ".", 3)
count := len(parts)
if count > 0 {
major = parseVersionPart(parts[0])
if count > 1 {
minor = parseVersionPart(parts[1])
if count > 2 {
patch = parseVersionPart(parts[2])
}
}
}
return (major << 16) | (minor << 8) | patch
}
// Version as a base-10 number. Used by monitoring.
func base10Version(hex int) int64 {
major := hex >> 16 & 0xFF
minor := hex >> 8 & 0xFF
trailer := hex & 0xFF
return int64(major*10000 + minor*100 + trailer)
}
func versionToString(vers int) string {
str := strconv.Itoa(vers>>16) + "." + strconv.Itoa((vers>>8)&0xff)
if vers&0xff != 0 {
str += "-" + strconv.Itoa(vers&0xff)
}
return str
}
// Tag handling
// filterTags takes a slice of tags and a map of namespaces, return a slice of namespace tags
// contained in the input.
// params: Tags to filter, namespaces to use as the filter.
func filterTags(tags []string, namespaces map[string]bool) []string {
var out []string
if len(namespaces) == 0 {
return out
}
for _, s := range tags {
parts := prefixedTagRegexp.FindStringSubmatch(s)
if len(parts) < 2 {
continue
}
// [1] is the prefix. [0] is the whole tag.
if namespaces[parts[1]] {
out = append(out, s)
}
}
return out
}
// rewriteTag attempts to match the original token against the email and telephone number.
// The tag is expected to be in lowercase.
// On success, it returns a slice with the original tag and the tag with the corresponding prefix. It returns an
// empty slice if the tag is invalid.
// TODO: consider inferring country code from user location.
func rewriteTag(orig, countryCode string) []string {
// Check if the tag already has a prefix e.g. basic:alice.
if prefixedTagRegexp.MatchString(orig) {
return []string{orig}
}
// Check if token can be rewritten by any of the validators
param := map[string]any{"countryCode": countryCode}
for name, conf := range globals.validators {
if conf.addToTags {
val := store.Store.GetValidator(name)
if tag, _ := val.PreCheck(orig, param); tag != "" {
return []string{orig, tag}
}
}
}
if tagRegexp.MatchString(orig) {
return []string{orig}
}
// invalid generic tag
return nil
}
// rewriteTagSlice calls rewriteTag for each slice member and return a new slice with original and converted values.
func rewriteTagSlice(tags []string, countryCode string) []string {
var result []string
for _, tag := range tags {
rewritten := rewriteTag(tag, countryCode)
if len(rewritten) != 0 {
result = append(result, rewritten...)
}
}
return result
}
// restrictedTagsEqual checks if two sets of tags contain the same set of restricted tags:
// true - same, false - different.
func restrictedTagsEqual(oldTags, newTags []string, namespaces map[string]bool) bool {
rold := filterTags(oldTags, namespaces)
rnew := filterTags(newTags, namespaces)
if len(rold) != len(rnew) {
return false
}
sort.Strings(rold)
sort.Strings(rnew)
// Match old tags against the new tags.
for i := range rnew {
if rold[i] != rnew[i] {
return false
}
}
return true
}
// Trim whitespace, remove short/empty tags and duplicates, convert to lowercase, ensure
// the number of tags does not exceed the maximum.
func normalizeTags(src []string, maxTags int) types.StringSlice {
if src == nil {
return nil
}
// Make sure the number of tags does not exceed the maximum.
// Technically it may result in fewer tags than the maximum due to empty tags and
// duplicates, but that's user's fault.
if len(src) > maxTags {
src = src[:maxTags]
}
// Trim whitespace and force to lowercase.
for i := range src {
src[i] = strings.ToLower(strings.TrimSpace(src[i]))
}
// Sort tags
sort.Strings(src)
// Remove short, invalid tags and de-dupe keeping the order. It may result in fewer tags than could have
// been if length were enforced later, but that's client's fault.
var prev string
var dst []string
for _, curr := range src {
if isNullValue(curr) {
// Return non-nil empty array
return make([]string, 0, 1)
}
// Unicode handling
ucurr := []rune(curr)
// Enforce length in characters, not in bytes.
if len(ucurr) < minTagLength || len(ucurr) > maxTagLength || curr == prev {
continue
}
// Make sure the tag starts with a letter or a number.
if unicode.IsLetter(ucurr[0]) || unicode.IsDigit(ucurr[0]) {
dst = append(dst, curr)
prev = curr
}
}
return types.StringSlice(dst)
}
func validateTag(tag string) (string, string) {
// Check if the tag already has a prefix e.g. basic:alice.
if parts := prefixedTagRegexp.FindStringSubmatch(tag); len(parts) == 3 {
// Valid prefixed tag.
return parts[1], parts[2]
}
if tagRegexp.MatchString(tag) {
// Valid unprefixed tag (tag value only).
return "", tag
}
return "", ""
}
// hasDuplicateNamespaceTags checks for duplication of unique NS tags.
// Each namespace can have only one tag. This does not prevent tags from
// being duplicate across requests, just saves an extra DB call.
func hasDuplicateNamespaceTags(src []string, uniqueNS string) bool {
found := map[string]bool{}
for _, tag := range src {
parts := prefixedTagRegexp.FindStringSubmatch(tag)
if len(parts) != 3 {
// Invalid tag, ignored.
continue
}
if uniqueNS == parts[1] && found[parts[1]] {
return true
}
found[parts[1]] = true
}
return false
}
// Parser for search queries. The query may contain non-ASCII characters,
// i.e. length of string in bytes != length of string in runes.
// Returns
// * required tags: AND tags (at least one must be present in every result),
// * optional tags
// * error.
func parseSearchQuery(query string) ([]string, []string, error) {
const (
NONE = iota
QUO // 1
AND // 2
OR // 3
END // 4
ORD // 5
)
type token struct {
op int
val string
}
type context struct {
// Pre-token operand.
preOp int
// Post-token operand.
postOp int
// Inside quoted string.
quo bool
// Start of the current token.
start int
// End of the current token.
end int
}
ctx := context{preOp: AND}
var out []token
var prev int
query = strings.TrimSpace(query)
// Split query into tokens.
// i - character index into the string.
// pos - rune index into the string.
// w - width of the current rune in characters.
for i, w, pos := 0, 0, 0; prev != END; i, pos = i+w, pos+1 {
//
var emit bool
// Lexer: get next rune.
var r rune
// Ordinary character by default.
curr := ORD
r, w = utf8.DecodeRuneInString(query[i:])
switch {
case w == 0:
// Width zero: end of the string.
curr = END
case r == '"':
// Quote opening or closing.
curr = QUO
case !ctx.quo:
// Not inside quoted string, test for control characters.
if r == ' ' || r == '\t' {
// Tab or space.
curr = AND
} else if r == ',' {
curr = OR
}
}
if curr == QUO {
if ctx.quo {
// End of the quoted string. Close the quote.
ctx.quo = false
} else {
if prev == ORD {
// Reject strings like a"b
return nil, nil, fmt.Errorf("missing operator at or near %d", pos)
}
// Start of the quoted string. Open the quote.
ctx.quo = true
}
// Treat quoted string as ordinary.
curr = ORD
}
// Parser: process the current lexem in context.
switch curr {
case OR:
if ctx.postOp == OR {
// More than one comma: ' , ,,'
return nil, nil, fmt.Errorf("invalid operator sequence at or near %d", pos)
}
// Ensure context is not "and", i.e. the case like ' ,' -> ','
ctx.postOp = OR
if prev == ORD {
// Close the current token.
ctx.end = i
}
case AND:
if prev == ORD {
// Close the current token.
ctx.end = i
ctx.postOp = AND
} else if ctx.postOp != OR {
// "and" does not change the "or" context.
ctx.postOp = AND
}
case ORD:
if prev == OR || prev == AND {
// Ordinary character after a comma or a space: ' a' or ',a'.
// Emit without changing the operation.
emit = true
}
case END:
if prev == ORD {
// Close the current token.
ctx.end = i
}
emit = true
}
if emit {
if ctx.quo && curr == END {
return nil, nil, fmt.Errorf("unterminated quoted string at or near %d %#v", pos, ctx)
}
// Emit the new token.
op := ctx.preOp
if ctx.postOp == OR {
op = OR
}
start, end := ctx.start, ctx.end
if query[start] == '"' && query[end-1] == '"' {
start++
end--
}
// Add token if non-empty.
if start < end {
out = append(out, token{val: strings.ToLower(query[start:end]), op: op})
}
ctx.start = i
ctx.preOp, ctx.postOp = ctx.postOp, NONE
}
prev = curr
}
if len(out) == 0 {
return nil, nil, nil
}
// Convert tokens to two string slices.
var and []string
var or []string
for _, t := range out {
switch t.op {
case AND:
and = append(and, t.val)
case OR:
or = append(or, t.val)
}
}
return and, or, nil
}
// Returns > 0 if v1 > v2; zero if equal; < 0 if v1 < v2
// Only Major and Minor parts are compared, the trailer is ignored.
func versionCompare(v1, v2 int) int {
return (v1 >> 8) - (v2 >> 8)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Truncate string if it's too long. Used in logging.
func truncateStringIfTooLong(s string) string {
if len(s) <= 1024 {
return s
}
return s[:1024] + "..."
}
// Convert relative filepath to absolute.
func toAbsolutePath(base, path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Clean(filepath.Join(base, path))
}
// Detect platform from the UserAgent string.
func platformFromUA(ua string) string {
ua = strings.ToLower(ua)
switch {
case strings.Contains(ua, "reactnative"):
switch {
case strings.Contains(ua, "iphone"),
strings.Contains(ua, "ipad"):
return "ios"
case strings.Contains(ua, "android"):
return "android"
}
return ""
case strings.Contains(ua, "tinodejs"):
return "web"
case strings.Contains(ua, "tindroid"):
return "android"
case strings.Contains(ua, "tinodios"):
return "ios"
}
return ""
}
func parseTLSConfig(tlsEnabled bool, jsconfig json.RawMessage) (*tls.Config, error) {
type tlsAutocertConfig struct {
// Domains to support by autocert
Domains []string `json:"domains"`
// Name of directory where auto-certificates are cached, e.g. /etc/letsencrypt/live/your-domain-here
CertCache string `json:"cache"`
// Contact email for letsencrypt
Email string `json:"email"`
}
type tlsConfig struct {
// Flag enabling TLS
Enabled bool `json:"enabled"`
// Listen for connections on this address:port and redirect them to HTTPS port.
RedirectHTTP string `json:"http_redirect"`
// Enable Strict-Transport-Security by setting max_age > 0
StrictMaxAge int `json:"strict_max_age"`
// ACME autocert config, e.g. letsencrypt.org
Autocert *tlsAutocertConfig `json:"autocert"`
// If Autocert is not defined, provide file names of static certificate and key
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
}
var config tlsConfig
if jsconfig != nil {
if err := json.Unmarshal(jsconfig, &config); err != nil {
return nil, errors.New("http: failed to parse tls_config: " + err.Error() + "(" + string(jsconfig) + ")")
}
}
if !tlsEnabled && !config.Enabled {
return nil, nil
}
if config.StrictMaxAge > 0 {
globals.tlsStrictMaxAge = strconv.Itoa(config.StrictMaxAge)
}
globals.tlsRedirectHTTP = config.RedirectHTTP
// If autocert is provided, use it.
if config.Autocert != nil {
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(config.Autocert.Domains...),
Cache: autocert.DirCache(config.Autocert.CertCache),
Email: config.Autocert.Email,
}
return certManager.TLSConfig(), nil
}
// Otherwise try to use static keys.
cert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile)
if err != nil {
return nil, err
}
return &tls.Config{Certificates: []tls.Certificate{cert}}, nil
}
// Merge source interface{} into destination interface.
// If values are maps,deep-merge them. Otherwise shallow-copy.
// Returns dst, true if the dst value was changed.
func mergeInterfaces(dst, src any) (any, bool) {
var changed bool
if src == nil {
return dst, changed
}
vsrc := reflect.ValueOf(src)
switch vsrc.Kind() {
case reflect.Map:
if xsrc, ok := src.(map[string]any); ok {
xdst, _ := dst.(map[string]any)
dst, changed = mergeMaps(xdst, xsrc)
} else {
changed = true
dst = src
}
case reflect.String:
if vsrc.String() == nullValue {
changed = dst != nil
dst = nil
} else {
changed = true
dst = src
}
default:
changed = true
dst = src
}
return dst, changed
}
// Deep copy maps.
func mergeMaps(dst, src map[string]any) (map[string]any, bool) {
var changed bool
if len(src) == 0 {
return dst, changed
}
if dst == nil {
dst = make(map[string]any)
}
for key, val := range src {
xval := reflect.ValueOf(val)
switch xval.Kind() {
case reflect.Map:
if xsrc, _ := val.(map[string]any); xsrc != nil {
// Deep-copy map[string]any
xdst, _ := dst[key].(map[string]any)
var lchange bool
dst[key], lchange = mergeMaps(xdst, xsrc)
changed = changed || lchange
} else if val != nil {
// The map is shallow-copied if it's not of the type map[string]any
dst[key] = val
changed = true
}
case reflect.String:
changed = true
if xval.String() == nullValue {
delete(dst, key)
} else if val != nil {
dst[key] = val
}
default:
if val != nil {
dst[key] = val
changed = true
}
}
}
return dst, changed
}
// Shallow copy of a map
func copyMap(src map[string]any) map[string]any {
dst := make(map[string]any, len(src))
maps.Copy(dst, src)
return dst
}
// netListener creates net.Listener for tcp and unix domains:
// if addr is in the form "unix:/run/tinode.sock" it's a unix socket, otherwise TCP host:port.
func netListener(addr string) (net.Listener, error) {
addrParts := strings.SplitN(addr, ":", 2)
if len(addrParts) == 2 && addrParts[0] == "unix" {
return net.Listen("unix", addrParts[1])
}
return net.Listen("tcp", addr)
}
// Check if specified address is a unix socket like "unix:/run/tinode.sock".
func isUnixAddr(addr string) bool {
addrParts := strings.SplitN(addr, ":", 2)
return len(addrParts) == 2 && addrParts[0] == "unix"
}
var privateIPBlocks []*net.IPNet
func isRoutableIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
if privateIPBlocks == nil {
for _, cidr := range []string{
"10.0.0.0/8", // RFC1918
"172.16.0.0/12", // RFC1918
"192.168.0.0/16", // RFC1918
"fc00::/7", // RFC4193, IPv6 unique local addr
} {
_, block, _ := net.ParseCIDR(cidr)
privateIPBlocks = append(privateIPBlocks, block)
}
}
for _, block := range privateIPBlocks {
if block.Contains(ip) {
return false
}
}
return true
}
+532
View File
@@ -0,0 +1,532 @@
package main
import (
"reflect"
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func slicesEqual(expected, gotten []string) bool {
if len(expected) != len(gotten) {
return false
}
for i, v := range expected {
if v != gotten[i] {
return false
}
}
return true
}
func expectSlicesEqual(t *testing.T, name string, expected, gotten []string) {
if !slicesEqual(expected, gotten) {
e := "'" + strings.Join(expected, "','") + "'"
g := "'" + strings.Join(gotten, "','") + "'"
t.Errorf("%s: expected %+v, got %+v", name, e, g)
}
}
func TestStringSliceDelta(t *testing.T) {
// Case format:
// - inputs: old, new
// - expected outputs: added, removed, intersection
cases := [][5][]string{
{
{"abc", "def", "fff"}, {},
{}, {"abc", "def", "fff"}, {},
},
{
{}, {}, {}, {}, {},
},
{
{"aa", "xx", "bb", "aa", "bb"}, {"yy", "aa"},
{"yy"}, {"aa", "bb", "bb", "xx"}, {"aa"},
},
{
{"bb", "aa", "bb"}, {"yy", "aa", "bb", "zzz", "zzz", "cc"},
{"cc", "yy", "zzz", "zzz"}, {"bb"}, {"aa", "bb"},
},
{
{"aa", "aa", "aa"}, {"aa", "aa", "aa"},
{}, {}, {"aa", "aa", "aa"},
},
}
for _, tc := range cases {
added, removed, both := stringSliceDelta(
tc[0], tc[1],
)
expectSlicesEqual(t, "added", tc[2], added)
expectSlicesEqual(t, "removed", tc[3], removed)
expectSlicesEqual(t, "both", tc[4], both)
}
}
func TestParseSearchQuery(t *testing.T) {
cases := []struct {
query string
expectedAnd []string
expectedOr []string
expectError bool
}{
{
query: `tag1 tag2 tag3`,
expectedAnd: []string{"tag1", "tag2", "tag3"},
expectedOr: []string{},
expectError: false,
},
{
query: `tag1,tag2,tag3`,
expectedAnd: []string{},
expectedOr: []string{"tag1", "tag2", "tag3"},
expectError: false,
},
{
query: `tag1 tag2,tag3`,
expectedAnd: []string{"tag1"},
expectedOr: []string{"tag2", "tag3"},
expectError: false,
},
{
query: `tag1 tag2,tag3 "tag4 tag5"`,
expectedAnd: []string{"tag1", "tag4 tag5"},
expectedOr: []string{"tag2", "tag3"},
expectError: false,
},
{
query: `tag1,tag2 tag3`,
expectedAnd: []string{"tag3"},
expectedOr: []string{"tag1", "tag2"},
expectError: false,
},
{
query: `"tag1 tag2" tag3,tag4`,
expectedAnd: []string{"tag1 tag2"},
expectedOr: []string{"tag3", "tag4"},
expectError: false,
},
{
query: `tag1 "tag2 tag3"`,
expectedAnd: []string{"tag1", "tag2 tag3"},
expectedOr: []string{},
expectError: false,
},
{
query: `tag1, tag2, tag3`,
expectedAnd: []string{},
expectedOr: []string{"tag1", "tag2", "tag3"},
expectError: false,
},
{
query: `tag1 , tag2 ,tag3`,
expectedAnd: []string{},
expectedOr: []string{"tag1", "tag2", "tag3"},
expectError: false,
},
{
query: `tag1 , tag2 tag3`,
expectedAnd: []string{"tag3"},
expectedOr: []string{"tag1", "tag2"},
expectError: false,
},
{
query: `tag1 "unterminated quote`,
expectedAnd: nil,
expectedOr: nil,
expectError: true,
},
{
query: `tag1,,tag2`,
expectedAnd: nil,
expectedOr: nil,
expectError: true,
},
{
query: `tag1 "tag2" tag3`,
expectedAnd: []string{"tag1", "tag2", "tag3"},
expectedOr: []string{},
expectError: false,
},
{
query: `tag1"tag2" quote in the middle`,
expectedAnd: nil,
expectedOr: nil,
expectError: true,
},
}
for _, tc := range cases {
and, or, err := parseSearchQuery(tc.query)
if tc.expectError {
if err == nil {
t.Errorf("expected error for query '%s', got none", tc.query)
}
} else {
if err != nil {
t.Errorf("unexpected error for query '%s': %v", tc.query, err)
} else {
expectSlicesEqual(t, tc.query+" AND", tc.expectedAnd, and)
expectSlicesEqual(t, tc.query+" OR", tc.expectedOr, or)
}
}
}
}
func TestNormalizeTags(t *testing.T) {
cases := []struct {
input []string
expected types.StringSlice
}{
{
input: []string{" Tag1 ", "tag2", "TAG3", "tag1"},
expected: types.StringSlice{"tag1", "tag2", "tag3"},
},
{
input: []string{" ", "tag2", "TAG3", "tag1"},
expected: types.StringSlice{"tag1", "tag2", "tag3"},
},
{
input: []string{"tag1"},
expected: types.StringSlice{"tag1"},
},
{
input: []string{"tag1", nullValue},
expected: []string{},
},
{
input: []string{},
expected: nil,
},
}
for _, tc := range cases {
got := normalizeTags(tc.input, 16)
expectSlicesEqual(t, "normalizeTags", tc.expected, got)
}
}
func TestRestrictedTagsEqual(t *testing.T) {
cases := []struct {
oldTags []string
newTags []string
namespaces map[string]bool
expected bool
}{
{
oldTags: []string{"ns1:tag1", "ns2:tag2"},
newTags: []string{"ns1:tag1", "ns2:tag2"},
namespaces: map[string]bool{"ns1": true, "ns2": true},
expected: true,
},
{
oldTags: []string{"ns1:tag1", "ns2:tag2"},
newTags: []string{"ns1:tag1", "ns2:tag3"},
namespaces: map[string]bool{"ns1": true, "ns2": true},
expected: false,
},
{
oldTags: []string{"ns1:tag1", "ns2:tag2"},
newTags: []string{"ns1:tag1"},
namespaces: map[string]bool{"ns1": true, "ns2": true},
expected: false,
},
}
for _, tc := range cases {
got := restrictedTagsEqual(tc.oldTags, tc.newTags, tc.namespaces)
if got != tc.expected {
t.Errorf("restrictedTagsEqual: expected %v, got %v", tc.expected, got)
}
}
}
func TestIsNullValue(t *testing.T) {
cases := []struct {
input any
expected bool
}{
{
input: nullValue,
expected: true,
},
{
input: "some string",
expected: false,
},
{
input: 123,
expected: false,
},
{
input: nil,
expected: false,
},
}
for _, tc := range cases {
got := isNullValue(tc.input)
if got != tc.expected {
t.Errorf("isNullValue: expected %v, got %v", tc.expected, got)
}
}
}
func TestParseVersion(t *testing.T) {
cases := []struct {
input string
expected int
}{
{
input: "1.2.3",
expected: (1 << 16) | (2 << 8) | 3,
},
{
input: "1.2",
expected: (1 << 16) | (2 << 8),
},
{
input: "1",
expected: (1 << 16),
},
{
input: "v1.2.3",
expected: (1 << 16) | (2 << 8) | 3,
},
{
input: "v1.2",
expected: (1 << 16) | (2 << 8),
},
{
input: "v1",
expected: (1 << 16),
},
{
input: "1.2.3-rc1",
expected: (1 << 16) | (2 << 8) | 3,
},
{
input: "v1.2.3-rc1",
expected: (1 << 16) | (2 << 8) | 3,
},
{
input: "0.0.0",
expected: 0,
},
{
input: "v0.0.0",
expected: 0,
},
{
input: "1.2.8192", // 8192 is greater than 0x1fff, should be ignored
expected: (1 << 16) | (2 << 8),
},
{
input: "1.8192.3", // 8192 is greater than 0x1fff, should be ignored
expected: (1 << 16) | 3,
},
{
input: "8192.2.3", // 8192 is greater than 0x1fff, should be ignored
expected: (2 << 8) | 3,
},
{
input: "",
expected: 0,
},
}
for _, tc := range cases {
got := parseVersion(tc.input)
if got != tc.expected {
t.Errorf("parseVersion(%q): expected %d, got %d", tc.input, tc.expected, got)
}
}
}
func TestMergeMaps(t *testing.T) {
cases := []struct {
dst map[string]any
src map[string]any
expected map[string]any
changed bool
}{
{
dst: map[string]any{"a": 1, "b": 2},
src: map[string]any{"b": 3, "c": 4},
expected: map[string]any{"a": 1, "b": 3, "c": 4},
changed: true,
},
{
dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}},
src: map[string]any{"b": map[string]any{"y": 2}},
expected: map[string]any{"a": 1, "b": map[string]any{"x": 1, "y": 2}},
changed: true,
},
{
dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}},
src: map[string]any{"b": map[string]any{"x": nullValue}},
expected: map[string]any{"a": 1, "b": map[string]any{}},
changed: true,
},
{
dst: map[string]any{"a": 1, "b": 2},
src: map[string]any{},
expected: map[string]any{"a": 1, "b": 2},
changed: false,
},
{
dst: nil,
src: map[string]any{"a": 1},
expected: map[string]any{"a": 1},
changed: true,
},
{
dst: map[string]any{"a": 1},
src: map[string]any{"a": nullValue},
expected: map[string]any{},
changed: true,
},
}
for _, tc := range cases {
got, changed := mergeMaps(tc.dst, tc.src)
if !reflect.DeepEqual(got, tc.expected) || changed != tc.changed {
t.Errorf("mergeMaps(%v, %v): expected (%v, %v), got (%v, %v)", tc.dst, tc.src, tc.expected, tc.changed, got, changed)
}
}
}
func TestMergeInterfaces(t *testing.T) {
cases := []struct {
dst any
src any
expected any
changed bool
}{
{
dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}},
src: map[string]any{"b": map[string]any{"y": 2}},
expected: map[string]any{"a": 1, "b": map[string]any{"x": 1, "y": 2}},
changed: true,
},
{
dst: map[string]any{"a": 1, "b": map[string]any{"x": 1}},
src: map[string]any{"b": map[string]any{"x": nullValue}},
expected: map[string]any{"a": 1, "b": map[string]any{}},
changed: true,
},
{
dst: map[string]any{"a": 1},
src: nullValue,
expected: nil,
changed: true,
},
{
dst: "old string",
src: "new string",
expected: "new string",
changed: true,
},
{
dst: "old string",
src: 12345,
expected: 12345,
changed: true,
},
{
dst: "old string",
src: nullValue,
expected: nil,
changed: true,
},
{
dst: 123,
src: 456,
expected: 456,
changed: true,
},
{
dst: 123,
src: nil,
expected: 123,
changed: false,
},
{
dst: 123,
src: true,
expected: true,
changed: true,
},
{
dst: []string{"a", "b", "c"},
src: []string{"d", "e", "f"},
expected: []string{"d", "e", "f"},
changed: true,
},
}
for _, tc := range cases {
got, changed := mergeInterfaces(tc.dst, tc.src)
if !reflect.DeepEqual(got, tc.expected) || changed != tc.changed {
t.Errorf("mergeInterfaces(%v, %v): expected (%v, %v), got (%v, %v)", tc.dst, tc.src, tc.expected, tc.changed, got, changed)
}
}
}
func TestFilterTags(t *testing.T) {
cases := []struct {
tags []string
ns map[string]bool
expected []string
}{
{
tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "},
ns: map[string]bool{"ns1": true, "ns2": false, "xtra": true},
expected: []string{"ns1:tag1"},
},
{
tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "},
ns: map[string]bool{},
expected: nil,
},
}
for _, tc := range cases {
got := filterTags(tc.tags, tc.ns)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("filterTags(%v, %v): expected (%v), got (%v)", tc.tags, tc.ns, tc.expected, got)
}
}
}
func TestHasDuplicateNamespaceTags(t *testing.T) {
cases := []struct {
tags []string
ns string
expected bool
}{
{
tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "},
ns: "ns1",
expected: false,
},
{
tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: ", "ns1:tag2"},
ns: "ns1",
expected: true,
},
{
tags: []string{"ns1:tag1", "ns2:tag3", "nons", "inval::tag", ":tag3", "tag4:", "tag5: "},
ns: "",
expected: false,
},
}
for _, tc := range cases {
got := hasDuplicateNamespaceTags(tc.tags, tc.ns)
if !reflect.DeepEqual(got, tc.expected) {
t.Errorf("filterTags(%v, %v): expected (%v), got (%v)", tc.tags, tc.ns, tc.expected, got)
}
}
}
+553
View File
@@ -0,0 +1,553 @@
// Package email is a credential validator which uses an external SMTP server.
package email
import (
"bytes"
crand "crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"math/rand"
"mime"
qp "mime/quotedprintable"
"net/mail"
"net/smtp"
"net/url"
"strconv"
"strings"
textt "text/template"
"slices"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
"github.com/tinode/chat/server/validate"
i18n "golang.org/x/text/language"
)
// Validator configuration.
type validator struct {
// Base URL of the web client.
HostUrl string `json:"host_url"`
// List of languages supported by templates.
Languages []string `json:"languages"`
// Path to email validation templates, either a template itself or a literal string.
ValidationTemplFile string `json:"validation_templ"`
// Path to templates for resetting the authentication secret.
ResetTemplFile string `json:"reset_secret_templ"`
// Sender RFC 5322 email address.
SendFrom string `json:"sender"`
// Login to use for SMTP authentication.
Login string `json:"login"`
// Password to use for SMTP authentication.
SenderPassword string `json:"sender_password"`
// Authentication mechanism to use, optional. One of "login", "md5", "plain" (default).
AuthMechanism string `json:"auth_mechanism"`
// Optional response which bypasses the validation.
DebugResponse string `json:"debug_response"`
// Number of validation attempts before email is locked.
MaxRetries int `json:"max_retries"`
// Address of the SMTP server.
SMTPAddr string `json:"smtp_server"`
// Port of the SMTP server.
SMTPPort string `json:"smtp_port"`
// ServerName used in SMTP HELO/EHLO command.
SMTPHeloHost string `json:"smtp_helo_host"`
// Skip verification of the server's certificate chain and host name.
// In this mode, TLS is susceptible to machine-in-the-middle attacks.
TLSInsecureSkipVerify bool `json:"insecure_skip_verify"`
// Optional whitelist of email domains accepted for registration.
Domains []string `json:"domains"`
// Length of secret numeric code to sent for validation.
CodeLength int `json:"code_length"`
// Must use index into language array instead of language tags because language.Matcher is brain damaged:
// https://github.com/golang/go/issues/24211
validationTempl []*textt.Template
resetTempl []*textt.Template
auth smtp.Auth
senderEmail string
langMatcher i18n.Matcher
maxCodeValue *big.Int
}
const (
validatorName = "email"
defaultMaxRetries = 3
defaultPort = "25"
// Technically email could be up to 255 bytes long but practically 128 is enough.
maxEmailLength = 128
// Default code length when one is not provided in the config
defaultCodeLength = 6
)
// Email template parts
var templateParts = []string{"subject", "body_plain", "body_html"}
// Init: initialize validator.
func (v *validator) Init(jsonconf string) error {
if err := json.Unmarshal([]byte(jsonconf), v); err != nil {
return err
}
sender, err := mail.ParseAddress(v.SendFrom)
if err != nil {
return err
}
v.senderEmail = sender.Address
// Enable auth if login is provided.
if v.Login != "" {
mechanism := strings.ToLower(v.AuthMechanism)
switch mechanism {
case "cram-md5":
v.auth = smtp.CRAMMD5Auth(v.Login, v.SenderPassword)
case "login":
v.auth = &loginAuth{[]byte(v.Login), []byte(v.SenderPassword)}
case "", "plain":
v.auth = smtp.PlainAuth("", v.Login, v.SenderPassword, v.SMTPAddr)
default:
return errors.New("unknown auth_mechanism")
}
}
// Optionally resolve paths.
v.ValidationTemplFile, err = validate.ResolveTemplatePath(v.ValidationTemplFile)
if err != nil {
return err
}
v.ResetTemplFile, err = validate.ResolveTemplatePath(v.ResetTemplFile)
if err != nil {
return err
}
// Paths to templates could be templates themselves: they may be language-dependent.
var validationPathTempl, resetPathTempl *textt.Template
validationPathTempl, err = textt.New("validation").Parse(v.ValidationTemplFile)
if err != nil {
return err
}
resetPathTempl, err = textt.New("reset").Parse(v.ResetTemplFile)
if err != nil {
return err
}
var path string
if len(v.Languages) > 0 {
v.validationTempl = make([]*textt.Template, len(v.Languages))
v.resetTempl = make([]*textt.Template, len(v.Languages))
var langTags []i18n.Tag
// Find actual content templates for each defined language.
for idx, lang := range v.Languages {
tag, err := i18n.Parse(lang)
if err != nil {
return err
}
langTags = append(langTags, tag)
if v.validationTempl[idx], path, err = validate.ReadTemplateFile(validationPathTempl, lang); err != nil {
return err
}
if err = isTemplateValid(v.validationTempl[idx]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
if v.resetTempl[idx], path, err = validate.ReadTemplateFile(resetPathTempl, lang); err != nil {
return err
}
if err = isTemplateValid(v.resetTempl[idx]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
}
v.langMatcher = i18n.NewMatcher(langTags)
} else {
v.validationTempl = make([]*textt.Template, 1)
v.resetTempl = make([]*textt.Template, 1)
// No i18n support. Use defaults.
v.validationTempl[0], path, err = validate.ReadTemplateFile(validationPathTempl, "")
if err != nil {
return err
}
if err = isTemplateValid(v.validationTempl[0]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
v.resetTempl[0], path, err = validate.ReadTemplateFile(resetPathTempl, "")
if err != nil {
return err
}
if err = isTemplateValid(v.resetTempl[0]); err != nil {
return fmt.Errorf("parsing %s: %w", path, err)
}
}
if v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil {
return err
}
if v.SMTPHeloHost == "" {
hostUrl, _ := url.Parse(v.HostUrl)
v.SMTPHeloHost = hostUrl.Hostname()
}
if v.SMTPHeloHost == "" {
return errors.New("missing SMTP host")
}
if v.MaxRetries == 0 {
v.MaxRetries = defaultMaxRetries
}
if v.CodeLength == 0 {
v.CodeLength = defaultCodeLength
}
v.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil)
if v.SMTPPort == "" {
v.SMTPPort = defaultPort
}
return nil
}
// IsInitialized returns true if the validator is initialized.
func (v *validator) IsInitialized() bool {
return v.SMTPHeloHost != ""
}
// PreCheck validates the credential and parameters without sending an email.
// If the credential is valid, it's returned with an appropriate prefix.
func (v *validator) PreCheck(cred string, _ map[string]any) (string, error) {
if len(cred) > maxEmailLength {
return "", t.ErrMalformed
}
// The email must be plain user@domain.
addr, err := mail.ParseAddress(cred)
if err != nil || addr.Address != cred {
return "", t.ErrMalformed
}
// Normalize email to make sure Unicode case collisions don't lead to security problems.
addr.Address = strings.ToLower(addr.Address)
// If a whitelist of domains is provided, make sure the email belongs to the list.
if len(v.Domains) > 0 {
// Parse email into user and domain parts.
parts := strings.Split(addr.Address, "@")
if len(parts) != 2 {
return "", t.ErrMalformed
}
if !slices.Contains(v.Domains, parts[1]) {
return "", t.ErrPolicy
}
}
return validatorName + ":" + addr.Address, nil
}
// Send a request for confirmation to the user: makes a record in DB and nothing else.
func (v *validator) Request(tenantID t.TenantID, user t.Uid, email, lang, resp string, tmpToken []byte) (bool, error) {
// Email validator cannot accept an immediate response.
if resp != "" {
return false, t.ErrFailed
}
// Normalize email to make sure Unicode case collisions don't lead to security problems.
email = strings.ToLower(email)
token := make([]byte, base64.StdEncoding.EncodedLen(len(tmpToken)))
base64.StdEncoding.Encode(token, tmpToken)
// Generate expected response as a random numeric string between 0 and 999999.
code, err := crand.Int(crand.Reader, v.maxCodeValue)
if err != nil {
return false, err
}
resp = strconv.FormatInt(code.Int64(), 10)
resp = strings.Repeat("0", v.CodeLength-len(resp)) + resp
var template *textt.Template
if v.langMatcher != nil {
// Find the template for the requested language.
// Make sure the language tag is standardized. Matcher is a bit dumber than Parse().
normalized, _ := i18n.Parse(lang)
// The matched tag is usually not in the list of available languages (e.g. es_ES -> es-u-rg-eszzzz).
// Use index to find the template instead of tag.
_, idx := i18n.MatchStrings(v.langMatcher, normalized.String())
template = v.validationTempl[idx]
} else {
template = v.validationTempl[0]
}
content, err := validate.ExecuteTemplate(template, templateParts, map[string]any{
"Token": url.QueryEscape(string(token)),
"Code": resp,
"HostUrl": v.HostUrl})
if err != nil {
return false, err
}
// Create or update validation record in DB.
isNew, err := store.Users.UpsertCred(tenantID, &t.Credential{
User: user.String(),
Method: validatorName,
Value: email,
Resp: resp})
if err != nil {
return false, err
}
// Send email without blocking. Email sending may take long time.
go v.send(email, content)
return isNew, nil
}
// ResetSecret sends a message with instructions for resetting an authentication secret.
func (v *validator) ResetSecret(tenantID t.TenantID, email, scheme, lang string, code []byte, params map[string]any) error {
// Normalize email to make sure Unicode case collisions don't lead to security problems.
email = strings.ToLower(email)
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.resetTempl[idx]
} else {
template = v.resetTempl[0]
}
var login string
if params != nil {
// Invariant: params["login"] is a string. Will panic if the invariant doesn't hold.
login = params["login"].(string)
}
content, err := validate.ExecuteTemplate(template, templateParts, map[string]any{
"Login": login,
"Code": string(code),
"Cred": email,
"Scheme": scheme,
"HostUrl": v.HostUrl})
if err != nil {
return err
}
// Send email without blocking. Email sending may take long time.
go v.send(email, content)
return nil
}
// Check checks if the provided validation response matches the expected response.
// Returns the value of validated credential on success.
func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) {
cred, err := store.Users.GetActiveCred(tenantID, user, validatorName)
if err != nil {
return "", err
}
if cred == nil {
// Request to validate non-existent credential.
return "", t.ErrNotFound
}
if cred.Retries > v.MaxRetries {
return "", t.ErrPolicy
}
if resp == "" {
return "", t.ErrCredentials
}
// Comparing with dummy response too.
if cred.Resp == resp || v.DebugResponse == resp {
// Valid response, save confirmation.
return cred.Value, store.Users.ConfirmCred(tenantID, user, validatorName)
}
// Invalid response, increment fail counter, ignore possible error.
store.Users.FailCred(tenantID, user, validatorName)
return "", t.ErrCredentials
}
// Delete deletes user's records.
func (v *validator) Delete(tenantID t.TenantID, user t.Uid) error {
return store.Users.DelCred(tenantID, user, validatorName, "")
}
// Remove deactivates or removes user's credential.
func (v *validator) Remove(tenantID t.TenantID, user t.Uid, value string) error {
return store.Users.DelCred(tenantID, user, validatorName, value)
}
// TempAuthScheme returns a temporary authentication method used by this validator.
func (v *validator) TempAuthScheme() (string, error) {
return "code", nil
}
// SendMail replacement
func (v *validator) sendMail(rcpt []string, msg []byte) error {
client, err := smtp.Dial(v.SMTPAddr + ":" + v.SMTPPort)
if err != nil {
return err
}
defer client.Close()
if err = client.Hello(v.SMTPHeloHost); err != nil {
return err
}
if istls, _ := client.Extension("STARTTLS"); istls {
tlsConfig := &tls.Config{
InsecureSkipVerify: v.TLSInsecureSkipVerify,
ServerName: v.SMTPAddr,
}
if err = client.StartTLS(tlsConfig); err != nil {
return err
}
}
if v.auth != nil {
if isauth, _ := client.Extension("AUTH"); isauth {
err = client.Auth(v.auth)
if err != nil {
return err
}
}
}
if err = client.Mail(strings.ReplaceAll(strings.ReplaceAll(v.senderEmail, "\r", " "), "\n", " ")); err != nil {
return err
}
for _, to := range rcpt {
if err = client.Rcpt(strings.ReplaceAll(strings.ReplaceAll(to, "\r", " "), "\n", " ")); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(msg); err != nil {
return err
}
if err = w.Close(); err != nil {
return err
}
return client.Quit()
}
// This is a basic SMTP sender which connects to a server using login/password.
// -
// See here how to send email from Amazon SES:
// https://docs.aws.amazon.com/sdk-for-go/api/service/ses/#example_SES_SendEmail_shared00
// -
// Mailjet and SendGrid have some free email limits.
func (v *validator) send(to string, content map[string]string) error {
message := &bytes.Buffer{}
// Common headers.
fmt.Fprintf(message, "From: %s\r\n", v.SendFrom)
fmt.Fprintf(message, "To: %s\r\n", to)
message.WriteString("Subject: ")
// Old email clients may barf on UTF-8 strings.
// Encode as quoted printable with 75-char strings separated by spaces, split by spaces, reassemble.
message.WriteString(strings.Join(strings.Split(mime.QEncoding.Encode("utf-8", content["subject"]), " "), "\r\n "))
message.WriteString("\r\n")
message.WriteString("MIME-version: 1.0;\r\n")
if content["body_html"] == "" {
// Plain text message
message.WriteString("Content-Type: text/plain; charset=\"UTF-8\"; format=flowed; delsp=yes\r\n")
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
b64w := base64.NewEncoder(base64.StdEncoding, message)
b64w.Write([]byte(content["body_plain"]))
b64w.Close()
} else if content["body_plain"] == "" {
// HTML-formatted message
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := qp.NewWriter(message)
qpw.Write([]byte(content["body_html"]))
qpw.Close()
} else {
// Multipart-alternative message includes both HTML and plain text components.
boundary := randomBoundary()
message.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
message.WriteString("--" + boundary + "\r\n")
message.WriteString("Content-Type: text/plain; charset=\"UTF-8\"; format=flowed; delsp=yes\r\n")
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
b64w := base64.NewEncoder(base64.StdEncoding, message)
b64w.Write([]byte(content["body_plain"]))
b64w.Close()
message.WriteString("\r\n")
message.WriteString("--" + boundary + "\r\n")
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qpw := qp.NewWriter(message)
qpw.Write([]byte(content["body_html"]))
qpw.Close()
message.WriteString("\r\n--" + boundary + "--")
}
message.WriteString("\r\n")
err := v.sendMail([]string{to}, message.Bytes())
if err != nil {
logs.Warn.Println("SMTP error", to, err)
}
return err
}
// Check if the template contains all required parts.
func isTemplateValid(templ *textt.Template) error {
if templ.Lookup("subject") == nil {
return fmt.Errorf("template invalid: '%s' not found", "subject")
}
if templ.Lookup("body_plain") == nil && templ.Lookup("body_html") == nil {
return fmt.Errorf("template invalid: neither of '%s', '%s' is found", "body_plain", "body_html")
}
return nil
}
type loginAuth struct {
username, password []byte
}
// Start begins an authentication with a server. Exported only to satisfy the interface definition.
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte(a.username), nil
}
// Next continues the authentication. Exported only to satisfy the interface definition.
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch strings.ToLower(string(fromServer)) {
case "username:":
return a.username, nil
case "password:":
return a.password, nil
default:
return nil, fmt.Errorf("LOGIN AUTH unknown server response '%s'", string(fromServer))
}
}
return nil, nil
}
func randomBoundary() string {
var buf [24]byte
rand.Read(buf[:])
return fmt.Sprintf("tinode--%x", buf[:])
}
func init() {
store.RegisterValidator(validatorName, &validator{})
}
+278
View File
@@ -0,0 +1,278 @@
// Package tel is an incomplete implementation of SMS or voice credential validator.
package tel
import (
"crypto/rand"
"encoding/json"
"math/big"
"strconv"
"strings"
textt "text/template"
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
"github.com/tinode/chat/server/validate"
i18n "golang.org/x/text/language"
)
// Empty placeholder struct.
type validator struct {
// Base URL of the web client to tell clients.
HostUrl string `json:"host_url"`
// List of languages supported by templates.
Languages []string `json:"languages"`
// Path to email validation and password reset templates, either a template itself or a literal string.
UniversalTemplFile string `json:"universal_templ"`
// Sender address (phone number).
Sender string `json:"sender"`
// Debug response to accept during testing.
DebugResponse string `json:"debug_response"`
// Maximum number of validation retires.
MaxRetries int `json:"max_retries"`
// Length of secret numeric code to sent for validation.
CodeLength int `json:"code_length"`
// Twilio-specific config.
Twilio json.RawMessage `json:"twilio_conf"`
// Must use index into language array instead of language tags because language.Matcher is brain damaged:
// https://github.com/golang/go/issues/24211
universalTempl []*textt.Template
langMatcher i18n.Matcher
maxCodeValue *big.Int
}
const (
validatorName = "tel"
defaultMaxRetries = 3
// Default code length when one is not provided in the config
defaultCodeLength = 6
defaultSender = "Tinode"
)
func (v *validator) Init(jsonconf string) error {
var err error
if err = json.Unmarshal([]byte(jsonconf), v); err != nil {
return err
}
if v.HostUrl, err = validate.ValidateHostURL(v.HostUrl); err != nil {
return err
}
var universalPathTempl *textt.Template
universalPathTempl, err = textt.New("universal").Parse(v.UniversalTemplFile)
if err != nil {
return err
}
if len(v.Languages) > 0 {
v.universalTempl = make([]*textt.Template, len(v.Languages))
var langTags []i18n.Tag
// Find actual content templates for each defined language.
for idx, lang := range v.Languages {
tag, err := i18n.Parse(lang)
if err != nil {
return err
}
langTags = append(langTags, tag)
if v.universalTempl[idx], _, err = validate.ReadTemplateFile(universalPathTempl, lang); err != nil {
return err
}
}
v.langMatcher = i18n.NewMatcher(langTags)
} else {
v.universalTempl = make([]*textt.Template, 1)
// No i18n support. Use defaults.
v.universalTempl[0], _, err = validate.ReadTemplateFile(universalPathTempl, "")
if err != nil {
return err
}
}
if v.Twilio != nil {
if err = twilioInit(v.Twilio); err != nil {
return err
}
}
if v.Sender == "" {
v.Sender = defaultSender
}
if v.MaxRetries == 0 {
v.MaxRetries = defaultMaxRetries
}
if v.CodeLength == 0 {
v.CodeLength = defaultCodeLength
}
v.maxCodeValue = big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(v.CodeLength)), nil)
return nil
}
// IsInitialized returns true if the validator is initialized.
func (v *validator) IsInitialized() bool {
return v.CodeLength > 0
}
// PreCheck validates the credential and parameters without sending an SMS or making the call.
// If credential is valid, it's formatted and prefixed with a tag namespace.
func (*validator) PreCheck(cred string, params map[string]any) (string, error) {
// Parse will try to extract the number from any text, make sure it's just the number.
if !phonenumbers.VALID_PHONE_NUMBER_PATTERN.MatchString(cred) {
return "", t.ErrMalformed
}
countryCode, ok := params["countryCode"].(string)
if !ok {
countryCode = "US"
}
number, err := phonenumbers.Parse(cred, countryCode)
if err != nil {
return "", t.ErrMalformed
}
if !phonenumbers.IsValidNumber(number) {
return "", t.ErrMalformed
}
if numType := phonenumbers.GetNumberType(number); numType != phonenumbers.FIXED_LINE_OR_MOBILE &&
numType != phonenumbers.MOBILE {
return "", t.ErrMalformed
}
return validatorName + ":" + phonenumbers.Format(number, phonenumbers.E164), nil
}
// Request sends a request for confirmation to the user: makes a record in DB and nothing else.
func (v *validator) Request(tenantID t.TenantID, user t.Uid, phone, lang, resp string, tmpToken []byte) (bool, error) {
// Phone validator cannot accept an immediate response.
if resp != "" {
return false, t.ErrFailed
}
// Generate expected response as a random numeric string between 0 and 999999.
code, err := rand.Int(rand.Reader, v.maxCodeValue)
if err != nil {
return false, err
}
resp = strconv.FormatInt(code.Int64(), 10)
resp = strings.Repeat("0", v.CodeLength-len(resp)) + resp
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.universalTempl[idx]
} else {
template = v.universalTempl[0]
}
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
"Code": resp,
"HostUrl": v.HostUrl})
if err != nil {
return false, err
}
// Create or update validation record in DB.
isNew, err := store.Users.UpsertCred(tenantID, &t.Credential{
User: user.String(),
Method: validatorName,
Value: phone,
Resp: resp})
if err != nil {
return false, err
}
// Send SMS without blocking. It sending may take long time.
go v.send(phone, content[""])
return isNew, nil
}
// ResetSecret sends a message with instructions for resetting an authentication secret.
func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string, code []byte, params map[string]any) error {
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.universalTempl[idx]
} else {
template = v.universalTempl[0]
}
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
"Code": string(code),
"HostUrl": v.HostUrl})
if err != nil {
return err
}
// Send SMS without blocking. Sending may take long time.
go v.send(phone, content[""])
return nil
}
// Check checks validity of user's response.
func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) {
cred, err := store.Users.GetActiveCred(tenantID, user, validatorName)
if err != nil {
return "", err
}
if cred == nil {
// Blank credential.
return "", t.ErrNotFound
}
if cred.Retries > v.MaxRetries {
return "", t.ErrPolicy
}
if resp == "" {
return "", t.ErrCredentials
}
// Comparing with dummy response too.
if cred.Resp == resp || v.DebugResponse == resp {
// Valid response, save confirmation.
return cred.Value, store.Users.ConfirmCred(tenantID, user, validatorName)
}
// Invalid response, increment fail counter, ignore possible error.
store.Users.FailCred(tenantID, user, validatorName)
return "", t.ErrCredentials
}
// Delete deletes user's records. Returns deleted credentials.
func (*validator) Delete(tenantID t.TenantID, user t.Uid) error {
return store.Users.DelCred(tenantID, user, validatorName, "")
}
// Remove or disable the given record.
func (*validator) Remove(tenantID t.TenantID, user t.Uid, value string) error {
return store.Users.DelCred(tenantID, user, validatorName, value)
}
// TempAuthScheme returns a temporary authentication method used by this validator.
func (v *validator) TempAuthScheme() (string, error) {
return "code", nil
}
// Implement sending the SMS.
func (v *validator) send(to, body string) error {
if v.Twilio != nil {
if err := twilioSend(v.Sender, to, body); err != nil {
logs.Warn.Println("Twilio SMS error", to, err)
}
} else {
logs.Info.Println("Send SMS, To:", to, "\nText:", body)
}
return nil
}
func init() {
store.RegisterValidator(validatorName, &validator{})
}
+5 -5
View File
@@ -33,7 +33,7 @@ type Validator interface {
// lang: user's human language as repored in the session.
// resp: optional response if user already has it (i.e. captcha/recaptcha).
// tmpToken: temporary authentication token to include in the request.
Request(user t.Uid, cred, lang, resp string, tmpToken []byte) (bool, error)
Request(tenantID t.TenantID, user t.Uid, cred, lang, resp string, tmpToken []byte) (bool, error)
// ResetSecret sends a message with instructions for resetting an authentication secret.
// cred: address to use for the message.
@@ -41,17 +41,17 @@ type Validator interface {
// lang: human language as reported in the session.
// tmpToken: temporary authentication token
// params: authentication params.
ResetSecret(cred, scheme, lang string, tmpToken []byte, params map[string]any) error
ResetSecret(tenantID t.TenantID, cred, scheme, lang string, tmpToken []byte, params map[string]any) error
// Check checks validity of user's response.
// Returns the value of validated credential on success.
Check(user t.Uid, resp string) (string, error)
Check(tenantID t.TenantID, user t.Uid, resp string) (string, error)
// Remove deletes or deactivates user's given value.
Remove(user t.Uid, value string) error
Remove(tenantID t.TenantID, user t.Uid, value string) error
// Delete deletes user's record.
Delete(user t.Uid) error
Delete(tenantID t.TenantID, user t.Uid) error
// TempAuthScheme returns a temporary authentication method used by this validator.
// It should be either "code" or "token".