344 lines
9.3 KiB
Go
344 lines
9.3 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
type WaitCallSession struct {
|
||
ID string `json:"session_id"`
|
||
Phone string `json:"phone"` // e.g. "79896657520"
|
||
TargetPhone string `json:"target_phone"` // e.g. "79019605980"
|
||
DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80"
|
||
Verified bool `json:"verified"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
}
|
||
|
||
type waitCallManager struct {
|
||
mu sync.RWMutex
|
||
sessions map[string]*WaitCallSession
|
||
phoneSessions map[string][]*WaitCallSession
|
||
lastAnswerPoll time.Time
|
||
}
|
||
|
||
var wcm = &waitCallManager{
|
||
sessions: make(map[string]*WaitCallSession),
|
||
phoneSessions: make(map[string][]*WaitCallSession),
|
||
}
|
||
|
||
func (m *waitCallManager) set(s *WaitCallSession) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.sessions[s.ID] = s
|
||
m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s)
|
||
}
|
||
|
||
func (m *waitCallManager) get(id string) *WaitCallSession {
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
s, ok := m.sessions[id]
|
||
if !ok || time.Now().After(s.ExpiresAt) {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (m *waitCallManager) isVerified(id, phone string) bool {
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
s, ok := m.sessions[id]
|
||
if !ok || time.Now().After(s.ExpiresAt) {
|
||
return false
|
||
}
|
||
norm := normalizeRuPhone(phone)
|
||
return s.Verified && (s.Phone == norm || s.Phone == phone)
|
||
}
|
||
|
||
// consume гасит подтверждение после успешной записи: одно подтверждение — одна запись.
|
||
func (m *waitCallManager) consume(id string) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if s, ok := m.sessions[id]; ok {
|
||
s.Verified = false
|
||
s.ExpiresAt = time.Now()
|
||
}
|
||
}
|
||
|
||
func (m *waitCallManager) markVerified(phone string) int {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
norm := normalizeRuPhone(phone)
|
||
list, ok := m.phoneSessions[norm]
|
||
if !ok {
|
||
return 0
|
||
}
|
||
count := 0
|
||
now := time.Now()
|
||
for _, s := range list {
|
||
if now.Before(s.ExpiresAt) {
|
||
s.Verified = true
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) {
|
||
if login == "" || password == "" {
|
||
return
|
||
}
|
||
m.mu.Lock()
|
||
if time.Since(m.lastAnswerPoll) < 3*time.Second {
|
||
m.mu.Unlock()
|
||
return
|
||
}
|
||
m.lastAnswerPoll = time.Now()
|
||
m.mu.Unlock()
|
||
|
||
endpoint := fmt.Sprintf(
|
||
"https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3",
|
||
url.QueryEscape(login),
|
||
url.QueryEscape(password),
|
||
)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var answers []struct {
|
||
ID any `json:"id"`
|
||
Phone string `json:"phone"`
|
||
Message string `json:"message"`
|
||
Text string `json:"text"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil {
|
||
return
|
||
}
|
||
|
||
for _, a := range answers {
|
||
txt := a.Message
|
||
if txt == "" {
|
||
txt = a.Text
|
||
}
|
||
if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" {
|
||
norm := normalizeRuPhone(a.Phone)
|
||
if norm != "" {
|
||
m.markVerified(norm)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX"
|
||
func formatRuPhone(p string) string {
|
||
d := onlyDigits(p)
|
||
if len(d) == 11 && (d[0] == '7' || d[0] == '8') {
|
||
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11])
|
||
}
|
||
if len(d) == 10 {
|
||
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10])
|
||
}
|
||
return "+" + d
|
||
}
|
||
|
||
// normalizeRuPhone converts various formats to "7XXXXXXXXXX"
|
||
func normalizeRuPhone(p string) string {
|
||
d := onlyDigits(p)
|
||
if len(d) == 10 {
|
||
return "7" + d
|
||
}
|
||
if len(d) == 11 && (d[0] == '8' || d[0] == '7') {
|
||
return "7" + d[1:]
|
||
}
|
||
return d
|
||
}
|
||
|
||
func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) {
|
||
if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) {
|
||
return
|
||
}
|
||
if a.smscLogin == "" || a.smscPassword == "" {
|
||
fail(w, http.StatusServiceUnavailable, "verification_disabled", "Подтверждение номера сейчас недоступно")
|
||
return
|
||
}
|
||
var in struct {
|
||
Phone string `json:"phone"`
|
||
}
|
||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil {
|
||
fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос")
|
||
return
|
||
}
|
||
phone := normalizeRuPhone(in.Phone)
|
||
if len(phone) != 11 || phone[0] != '7' {
|
||
fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона")
|
||
return
|
||
}
|
||
if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) {
|
||
return
|
||
}
|
||
|
||
endpoint := fmt.Sprintf(
|
||
"https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3",
|
||
url.QueryEscape(a.smscLogin),
|
||
url.QueryEscape(a.smscPassword),
|
||
url.QueryEscape(phone),
|
||
)
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
// в адресе логин и пароль SMSC — в лог пишем ошибку без него
|
||
slog.Error("smsc wait_call request failed", "err", redactURL(err))
|
||
fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен")
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var smscResp struct {
|
||
Phone string `json:"phone"`
|
||
AllPhones []string `json:"all_phones"`
|
||
Error string `json:"error"`
|
||
ErrorCode int `json:"error_code"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil {
|
||
slog.Error("smsc wait_call decode failed", "err", err)
|
||
fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки")
|
||
return
|
||
}
|
||
if smscResp.ErrorCode != 0 {
|
||
slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error)
|
||
msg := "Не удалось получить номер для проверки"
|
||
if smscResp.ErrorCode == 9 {
|
||
msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту."
|
||
}
|
||
fail(w, http.StatusBadRequest, "smsc_error", msg)
|
||
return
|
||
}
|
||
|
||
targetPhone := smscResp.Phone
|
||
if targetPhone == "" && len(smscResp.AllPhones) > 0 {
|
||
targetPhone = smscResp.AllPhones[0]
|
||
}
|
||
if targetPhone == "" {
|
||
fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка")
|
||
return
|
||
}
|
||
|
||
b := make([]byte, 16)
|
||
_, _ = rand.Read(b)
|
||
sessionID := hex.EncodeToString(b)
|
||
|
||
sess := &WaitCallSession{
|
||
ID: sessionID,
|
||
Phone: phone,
|
||
TargetPhone: targetPhone,
|
||
DisplayPhone: formatRuPhone(targetPhone),
|
||
Verified: false,
|
||
CreatedAt: time.Now(),
|
||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||
}
|
||
wcm.set(sess)
|
||
|
||
ok(w, map[string]any{
|
||
"session_id": sess.ID,
|
||
"target_phone": sess.TargetPhone,
|
||
"display_phone": sess.DisplayPhone,
|
||
"expires_in": 600,
|
||
}, nil)
|
||
}
|
||
|
||
func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) {
|
||
sessionID := r.URL.Query().Get("session_id")
|
||
if sessionID == "" {
|
||
fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id")
|
||
return
|
||
}
|
||
sess := wcm.get(sessionID)
|
||
if sess == nil {
|
||
fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла")
|
||
return
|
||
}
|
||
|
||
if !sess.Verified {
|
||
wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone)
|
||
}
|
||
|
||
ok(w, map[string]any{
|
||
"session_id": sess.ID,
|
||
"phone": sess.Phone,
|
||
"target_phone": sess.TargetPhone,
|
||
"display_phone": sess.DisplayPhone,
|
||
"verified": sess.Verified,
|
||
}, nil)
|
||
}
|
||
|
||
func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) {
|
||
// Обратный вызов приходит из интернета, поэтому без секрета в адресе его не принимаем:
|
||
// подтверждение номера всё равно придёт через опрос ответов SMSC (waitCallStatus).
|
||
if a.smscCallbackToken == "" || !sameKey(r.URL.Query().Get("token"), a.smscCallbackToken) {
|
||
slog.Warn("smsc: callback rejected (нет или неверный token)", "ip", clientIP(r))
|
||
fail(w, http.StatusForbidden, "forbidden", "Запрещено")
|
||
return
|
||
}
|
||
_ = r.ParseForm()
|
||
phone := r.FormValue("phone")
|
||
if phone == "" {
|
||
phone = r.URL.Query().Get("phone")
|
||
}
|
||
waitcall := r.FormValue("waitcall")
|
||
if waitcall == "" {
|
||
waitcall = r.URL.Query().Get("waitcall")
|
||
}
|
||
|
||
if phone == "" && r.Body != nil {
|
||
var j struct {
|
||
Phone string `json:"phone"`
|
||
Waitcall any `json:"waitcall"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&j)
|
||
if j.Phone != "" {
|
||
phone = j.Phone
|
||
}
|
||
if j.Waitcall != nil {
|
||
waitcall = fmt.Sprint(j.Waitcall)
|
||
}
|
||
}
|
||
|
||
slog.Info("smsc: callback received", "phone", maskPhone(phone), "waitcall", waitcall)
|
||
|
||
norm := normalizeRuPhone(phone)
|
||
if norm != "" {
|
||
verifiedCount := wcm.markVerified(norm)
|
||
slog.Info("smsc: sessions marked verified", "phone", maskPhone(norm), "count", verifiedCount)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("OK"))
|
||
}
|