| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- package tgbot
- import (
- "encoding/base64"
- "html"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- "github.com/mhsanaei/3x-ui/v3/internal/util/common"
- "github.com/mymmrac/telego"
- )
- type inviteOutcome int
- const (
- inviteInvalid inviteOutcome = iota
- inviteTaken
- inviteAlreadyOwned
- inviteBindable
- )
- // A client's SubID doubles as its invite token: whoever holds it can already
- // fetch the subscription, so binding grants no access the token did not.
- func (t *Tgbot) resolveInviteToken(token string, fromID int64) (inviteOutcome, []*model.ClientRecord) {
- token = strings.TrimSpace(token)
- if token == "" || fromID <= 0 {
- return inviteInvalid, nil
- }
- records, err := t.clientService.GetRecordsBySubID(token)
- if err != nil || len(records) == 0 {
- return inviteInvalid, nil
- }
- return classifyInvite(records, fromID), records
- }
- // One subscription can span several clients, so a token is claimable only when
- // no part of it belongs to someone else.
- func classifyInvite(records []*model.ClientRecord, fromID int64) inviteOutcome {
- unbound := false
- for _, record := range records {
- switch record.TgID {
- case 0:
- unbound = true
- case fromID:
- default:
- return inviteTaken
- }
- }
- if unbound {
- return inviteBindable
- }
- return inviteAlreadyOwned
- }
- // Claims run on concurrent handlers, so resolving and binding happen under one
- // lock: a second claimant must see the first one's binding, not the rows it read.
- var inviteClaimMu sync.Mutex
- // claimInvite reports the outcome it told the user, bindErr aside, so a caller
- // can tell a bind that landed from a refusal without reading the reply.
- func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOutcome {
- token, ok := decodeInvitePayload(payload)
- if !ok {
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
- return inviteInvalid
- }
- inviteClaimMu.Lock()
- outcome, records := t.resolveInviteToken(token, fromID)
- var bindErr error
- if outcome == inviteBindable {
- bindErr = t.bindRecordsToUser(records, fromID)
- }
- inviteClaimMu.Unlock()
- switch outcome {
- case inviteAlreadyOwned:
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
- case inviteBindable:
- if bindErr != nil {
- logger.Warning("tgbot: invite bind failed:", bindErr)
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
- return inviteInvalid
- }
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteBound", "Email=="+recordEmails(records)))
- default:
- // Unknown and already-claimed tokens share one reply, so a prober cannot
- // tell a valid SubID from an invalid one.
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteInvalid"))
- }
- return outcome
- }
- func recordEmails(records []*model.ClientRecord) string {
- emails := make([]string, 0, len(records))
- for _, record := range records {
- emails = append(emails, record.Email)
- }
- return strings.Join(emails, ", ")
- }
- // Every unbound client behind the token is bound, so a subscription spanning
- // several inbounds does not leave the customer holding only part of it. A failure
- // part-way undoes this claim's bindings, so the reply never hides a half-bind.
- func (t *Tgbot) bindRecordsToUser(records []*model.ClientRecord, tgID int64) error {
- var bound []int
- for _, record := range records {
- if record.TgID != 0 {
- continue
- }
- traffic, err := t.inboundService.GetClientTrafficByEmail(record.Email)
- if err == nil && traffic == nil {
- err = common.NewError("no traffic record for client:", record.Email)
- }
- if err == nil {
- err = t.setClientTgID(traffic.Id, tgID)
- }
- if err != nil {
- for _, trafficID := range bound {
- if undoErr := t.setClientTgID(trafficID, EmptyTelegramUserID); undoErr != nil {
- logger.Warning("tgbot: undoing partial invite bind failed:", undoErr)
- }
- }
- return err
- }
- bound = append(bound, traffic.Id)
- }
- return nil
- }
- func (t *Tgbot) setClientTgID(trafficID int, tgID int64) error {
- needRestart, err := t.clientService.SetClientTelegramUserID(&t.inboundService, trafficID, tgID)
- if needRestart {
- t.xrayService.SetToNeedRestart()
- }
- return err
- }
- // Telegram accepts only A-Za-z0-9_- in a start payload, at most 64 characters,
- // while a subId may hold '#', '&' or non-ASCII; base64url carries any subId
- // that fits intact instead of letting the link truncate it into another one.
- const maxInvitePayload = 64
- func encodeInvitePayload(subID string) (string, bool) {
- payload := base64.RawURLEncoding.EncodeToString([]byte(subID))
- return payload, len(payload) <= maxInvitePayload
- }
- func decodeInvitePayload(payload string) (string, bool) {
- raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(payload))
- if err != nil || len(raw) == 0 {
- return "", false
- }
- return string(raw), true
- }
- func (t *Tgbot) sendInviteLink(chatId int64, email string) {
- record, err := t.clientService.GetRecordByEmail(nil, email)
- username := botUsername()
- if err != nil || record.SubID == "" || username == "" {
- logger.Warning("tgbot: invite link unavailable for", email, err)
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
- return
- }
- payload, ok := encodeInvitePayload(record.SubID)
- if !ok {
- logger.Warning("tgbot: subId of", email, "is too long for a Telegram invite link")
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"))
- return
- }
- link := "https://t.me/" + username + "?start=" + payload
- t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.inviteLink", "Email=="+email, "Link=="+link))
- }
- // A subId can be short or human-readable, so claim attempts are capped per
- // Telegram account: guessing stays slow, and admins hear about whoever tries.
- const (
- inviteAttemptLimit = 5
- inviteAttemptWindow = time.Hour
- )
- type inviteAttempts struct {
- windowStart time.Time
- count int
- }
- var (
- inviteAttemptsMu sync.Mutex
- inviteAttemptsBy = map[int64]*inviteAttempts{}
- inviteAttemptsNow = time.Now
- )
- // allowInviteAttempt counts one claim attempt and reports whether it may run.
- // Admins are told once per window, on the first attempt past the limit.
- func (t *Tgbot) allowInviteAttempt(from *telego.User) bool {
- now := inviteAttemptsNow()
- inviteAttemptsMu.Lock()
- for id, a := range inviteAttemptsBy {
- if now.Sub(a.windowStart) >= inviteAttemptWindow {
- delete(inviteAttemptsBy, id)
- }
- }
- a, ok := inviteAttemptsBy[from.ID]
- if !ok {
- a = &inviteAttempts{windowStart: now}
- inviteAttemptsBy[from.ID] = a
- }
- a.count++
- count := a.count
- inviteAttemptsMu.Unlock()
- if count == inviteAttemptLimit+1 {
- t.SendMsgToTgbotAdmins(t.I18nBot("tgbot.messages.inviteRateLimitedAdmin",
- "User=="+tgUserMention(from),
- "ID=="+strconv.FormatInt(from.ID, 10),
- "Limit=="+strconv.Itoa(inviteAttemptLimit)))
- }
- return count <= inviteAttemptLimit
- }
- func tgUserMention(from *telego.User) string {
- id := strconv.FormatInt(from.ID, 10)
- name := strings.TrimSpace(from.FirstName + " " + from.LastName)
- if name == "" {
- name = id
- }
- mention := `<a href="tg://user?id=` + id + `">` + html.EscapeString(name) + `</a>`
- if from.Username != "" {
- mention += " @" + html.EscapeString(from.Username)
- }
- return mention
- }
|