[SMS] Diagnose Kavenegar failures; sanitize API key in URL path
CI/CD / CI · dotnet build (push) Successful in 1m2s
CI/CD / Deploy · hamkadr (push) Successful in 57s

A 404 from Kavenegar means a malformed URL path, and the API key sits in the path unescaped, so a stray space/newline/slash in the saved key breaks it. Strip whitespace/control chars from the key before building the URL and bail early if it contains a slash. Also read and log Kavenegar's response body and return.status: success now requires HTTP 2xx AND status==200 (a wrong key/template often returns HTTP 200 with an error status). Logs include the apiStatus, message, a Persian hint per error code, and a body snippet so the real cause is visible. No schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 16:40:05 +03:30
parent 1f34fd126f
commit acec73a3d2
+59 -9
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using JobsMedical.Web.Models; using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services; namespace JobsMedical.Web.Services;
@@ -27,38 +28,87 @@ public class KavenegarSmsSender : ISmsSender
public async Task<bool> SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default) public async Task<bool> SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default)
{ {
if (!s.SmsEnabled || string.IsNullOrWhiteSpace(s.SmsApiKey)) return false; 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 try
{ {
var client = _http.CreateClient("sms"); var client = _http.CreateClient("sms");
client.Timeout = TimeSpan.FromSeconds(15); client.Timeout = TimeSpan.FromSeconds(15);
string url; string url; string method;
if (!string.IsNullOrWhiteSpace(s.SmsTemplate)) if (!string.IsNullOrWhiteSpace(s.SmsTemplate))
{ {
method = "verify/lookup";
// verify/lookup: template contains %token → the code // 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)}" + $"?receptor={Uri.EscapeDataString(phone)}&token={Uri.EscapeDataString(code)}" +
$"&template={Uri.EscapeDataString(s.SmsTemplate)}"; $"&template={Uri.EscapeDataString(s.SmsTemplate.Trim())}";
} }
else else
{ {
method = "sms/send";
var msg = $"کد ورود همکادر: {code}"; 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)}" + $"?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); using var resp = await client.GetAsync(url, ct);
if (!resp.IsSuccessStatusCode) 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
{ {
_log.LogWarning("Kavenegar OTP HTTP {Status} for {Phone}", (int)resp.StatusCode, phone); 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; return false;
} }
return true;
}
catch (Exception ex) catch (Exception ex)
{ {
_log.LogWarning(ex, "Kavenegar OTP send failed for {Phone}", phone); _log.LogWarning(ex, "Kavenegar OTP send failed for {Phone}", phone);
return false; return false;
} }
} }
/// <summary>Parse Kavenegar's { "return": { "status": N, "message": "…" } } envelope.</summary>
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] + "…");
} }