diff --git a/src/JobsMedical.Web/Services/SmsSender.cs b/src/JobsMedical.Web/Services/SmsSender.cs index 6f389a5..ba44765 100644 --- a/src/JobsMedical.Web/Services/SmsSender.cs +++ b/src/JobsMedical.Web/Services/SmsSender.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using JobsMedical.Web.Models; namespace JobsMedical.Web.Services; @@ -27,33 +28,61 @@ public class KavenegarSmsSender : ISmsSender public async Task SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default) { if (!s.SmsEnabled || string.IsNullOrWhiteSpace(s.SmsApiKey)) return false; + + // The API key is part of the URL path; clean it so a stray space/newline/slash + // doesn't turn the request into a 404 (a malformed path). It is NOT a query value, + // so don't percent-encode it — just strip whitespace/control chars. + var apiKey = new string(s.SmsApiKey.Where(c => !char.IsWhiteSpace(c)).ToArray()); + if (apiKey.Contains('/')) + { + _log.LogWarning("Kavenegar API key looks malformed (contains '/'). Check the value in admin settings."); + return false; + } + try { var client = _http.CreateClient("sms"); client.Timeout = TimeSpan.FromSeconds(15); - string url; + string url; string method; if (!string.IsNullOrWhiteSpace(s.SmsTemplate)) { + method = "verify/lookup"; // verify/lookup: template contains %token → the code - url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/verify/lookup.json" + + url = $"https://api.kavenegar.com/v1/{apiKey}/verify/lookup.json" + $"?receptor={Uri.EscapeDataString(phone)}&token={Uri.EscapeDataString(code)}" + - $"&template={Uri.EscapeDataString(s.SmsTemplate)}"; + $"&template={Uri.EscapeDataString(s.SmsTemplate.Trim())}"; } else { + method = "sms/send"; var msg = $"کد ورود همکادر: {code}"; - url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/sms/send.json" + + url = $"https://api.kavenegar.com/v1/{apiKey}/sms/send.json" + $"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(msg)}" + - (string.IsNullOrWhiteSpace(s.SmsSender) ? "" : $"&sender={Uri.EscapeDataString(s.SmsSender)}"); + (string.IsNullOrWhiteSpace(s.SmsSender) ? "" : $"&sender={Uri.EscapeDataString(s.SmsSender.Trim())}"); } using var resp = await client.GetAsync(url, ct); - if (!resp.IsSuccessStatusCode) - { - _log.LogWarning("Kavenegar OTP HTTP {Status} for {Phone}", (int)resp.StatusCode, phone); - return false; - } - return true; + var body = await resp.Content.ReadAsStringAsync(ct); + var (apiStatus, apiMessage) = ParseKavenegar(body); + + // Kavenegar success = HTTP 2xx AND return.status == 200. A wrong key/template + // often comes back as HTTP 200 with an error status, so check both. + if (resp.IsSuccessStatusCode && apiStatus == 200) return true; + + string hint = (int)resp.StatusCode == 404 + ? " — HTTP 404 معمولاً یعنی کلید API نادرست/خراب است یا متد اشتباه (مسیر آدرس معتبر نیست)." + : apiStatus switch + { + 401 or 402 => " — کلید API نامعتبر یا حساب غیرفعال است.", + 407 or 431 or 432 => " — متن/تمپلیت تأیید نشده یا نام تمپلیت اشتباه است.", + 411 or 412 => " — شماره گیرنده یا فرستنده نامعتبر است.", + 418 => " — اعتبار حساب کافی نیست.", + _ => "" + }; + _log.LogWarning( + "Kavenegar OTP failed for {Phone} via {Method}: HTTP {Http}, apiStatus={ApiStatus}, message={Message}{Hint} | body: {Body}", + phone, method, (int)resp.StatusCode, apiStatus, apiMessage, hint, Truncate(body, 300)); + return false; } catch (Exception ex) { @@ -61,4 +90,25 @@ public class KavenegarSmsSender : ISmsSender return false; } } + + /// Parse Kavenegar's { "return": { "status": N, "message": "…" } } envelope. + private static (int? status, string? message) ParseKavenegar(string body) + { + if (string.IsNullOrWhiteSpace(body)) return (null, null); + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("return", out var ret)) + { + int? status = ret.TryGetProperty("status", out var st) && st.TryGetInt32(out var n) ? n : null; + string? msg = ret.TryGetProperty("message", out var m) ? m.GetString() : null; + return (status, msg); + } + } + catch (JsonException) { /* not JSON (e.g. a 404 HTML page) — fall through */ } + return (null, null); + } + + private static string Truncate(string s, int max) => + string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max] + "…"); }