Add SEO sitemap/robots + real SMS OTP (Kavenegar, admin-configured)
- /sitemap.xml (static pages + open shifts + fresh jobs, respecting expiry) + /robots.txt (blocks /Admin,/Employer); base URL from forwarded request → https://hamkadr.ir in prod - ISmsSender + KavenegarSmsSender (verify/lookup template, sms/send fallback); SMS settings (enabled/apikey/template/sender) in /Admin/Settings; OtpService.IssueAsync sends SMS and stops revealing the code when enabled (dev still shows it); migration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,44 @@
|
||||
using JobsMedical.Web.Services.Scraping;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One-time-code issuing/verification. Codes live in memory for 5 minutes. In dev the code is
|
||||
/// returned to the caller so it can be shown on screen; in production this is where an Iranian
|
||||
/// SMS gateway (Kavenegar / SMS.ir) would send the code instead.
|
||||
/// One-time-code issuing/verification. Codes live in memory for 5 minutes. When SMS is configured
|
||||
/// (admin settings) the code is sent via the gateway and NOT returned; otherwise it's returned so
|
||||
/// the dev login page can display it.
|
||||
/// </summary>
|
||||
public class OtpService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
public OtpService(IMemoryCache cache) => _cache = cache;
|
||||
private readonly ISmsSender _sms;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public OtpService(IMemoryCache cache, ISmsSender sms, SettingsService settings)
|
||||
{
|
||||
_cache = cache;
|
||||
_sms = sms;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
private static string Key(string phone) => $"otp:{Normalize(phone)}";
|
||||
|
||||
/// <summary>Generate, store, and (in dev) return a 5-digit code for the phone.</summary>
|
||||
public string Issue(string phone)
|
||||
/// <summary>
|
||||
/// Generate + store a 5-digit code. If SMS is enabled, send it and return null (don't reveal);
|
||||
/// otherwise return the code so the dev login screen can show it.
|
||||
/// </summary>
|
||||
public async Task<string?> IssueAsync(string phone)
|
||||
{
|
||||
var code = Random.Shared.Next(10000, 100000).ToString();
|
||||
_cache.Set(Key(phone), code, TimeSpan.FromMinutes(5));
|
||||
// TODO(prod): send `code` via Kavenegar/SMS.ir instead of returning it.
|
||||
return code;
|
||||
|
||||
var settings = await _settings.GetAsync();
|
||||
if (settings.SmsEnabled)
|
||||
{
|
||||
await _sms.SendOtpAsync(phone, code, settings);
|
||||
return null; // never reveal the code in production
|
||||
}
|
||||
return code; // dev: surface it on screen
|
||||
}
|
||||
|
||||
public bool Verify(string phone, string code)
|
||||
|
||||
@@ -46,6 +46,10 @@ public class SettingsService
|
||||
s.DivarQueries = incoming.DivarQueries?.Trim();
|
||||
s.MedjobsEnabled = incoming.MedjobsEnabled;
|
||||
s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500);
|
||||
s.SmsEnabled = incoming.SmsEnabled;
|
||||
s.SmsApiKey = incoming.SmsApiKey?.Trim();
|
||||
s.SmsTemplate = incoming.SmsTemplate?.Trim();
|
||||
s.SmsSender = incoming.SmsSender?.Trim();
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using JobsMedical.Web.Models;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
public interface ISmsSender
|
||||
{
|
||||
/// <summary>Send the OTP code. Returns false if not configured or the gateway call fails.</summary>
|
||||
Task<bool> SendOtpAsync(string phone, string code, AppSetting settings, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kavenegar SMS gateway (Iran). Prefers the verify/lookup API (a pre-approved OTP template, no
|
||||
/// dedicated line needed); falls back to plain sms/send if only a sender line is configured.
|
||||
/// Credentials live in AppSetting (admin panel), so no redeploy to set them.
|
||||
/// </summary>
|
||||
public class KavenegarSmsSender : ISmsSender
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly ILogger<KavenegarSmsSender> _log;
|
||||
|
||||
public KavenegarSmsSender(IHttpClientFactory http, ILogger<KavenegarSmsSender> log)
|
||||
{
|
||||
_http = http;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<bool> SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default)
|
||||
{
|
||||
if (!s.SmsEnabled || string.IsNullOrWhiteSpace(s.SmsApiKey)) return false;
|
||||
try
|
||||
{
|
||||
var client = _http.CreateClient("sms");
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
string url;
|
||||
if (!string.IsNullOrWhiteSpace(s.SmsTemplate))
|
||||
{
|
||||
// verify/lookup: template contains %token → the code
|
||||
url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/verify/lookup.json" +
|
||||
$"?receptor={Uri.EscapeDataString(phone)}&token={Uri.EscapeDataString(code)}" +
|
||||
$"&template={Uri.EscapeDataString(s.SmsTemplate)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var msg = $"کد ورود همکادر: {code}";
|
||||
url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/sms/send.json" +
|
||||
$"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(msg)}" +
|
||||
(string.IsNullOrWhiteSpace(s.SmsSender) ? "" : $"&sender={Uri.EscapeDataString(s.SmsSender)}");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Kavenegar OTP send failed for {Phone}", phone);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user