diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml index 0ad9aea..ffdbb41 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml @@ -77,6 +77,11 @@ +
+ + + +
} diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs index 07fa710..ec041c8 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using JobsMedical.Web.Data; using JobsMedical.Web.Models; +using JobsMedical.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -12,11 +13,20 @@ namespace JobsMedical.Web.Pages.Employer; public class PostJobModel : PageModel { private readonly AppDbContext _db; - public PostJobModel(AppDbContext db) => _db = db; + private readonly CaptchaService _captcha; + private readonly SubmissionGuard _guard; + + public PostJobModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard) + { + _db = db; + _captcha = captcha; + _guard = guard; + } public List MyFacilities { get; private set; } = new(); public List Roles { get; private set; } = new(); public string? Error { get; private set; } + public string CaptchaQuestion { get; private set; } = ""; [BindProperty] public int FacilityId { get; set; } [BindProperty] public int RoleId { get; set; } @@ -28,18 +38,30 @@ public class PostJobModel : PageModel [BindProperty] public Gender GenderRequirement { get; set; } [BindProperty] public string? Description { get; set; } [BindProperty] public string? Requirements { get; set; } + [BindProperty] public string? CaptchaToken { get; set; } + [BindProperty] public string? CaptchaAnswer { get; set; } - public async Task OnGetAsync() => await LoadListsAsync(); + public async Task OnGetAsync() { await LoadListsAsync(); NewCaptcha(); } public async Task OnPostAsync() { await LoadListsAsync(); + + // 1. Bot gate — built-in captcha. + if (!_captcha.Verify(CaptchaToken, CaptchaAnswer)) + { Error = "پاسخ سؤال امنیتی نادرست است."; NewCaptcha(); return Page(); } + if (!MyFacilities.Any(f => f.Id == FacilityId)) - { - Error = "این مرکز متعلق به شما نیست."; - return Page(); - } - if (string.IsNullOrWhiteSpace(Title)) { Error = "عنوان موقعیت الزامی است."; return Page(); } + { Error = "این مرکز متعلق به شما نیست."; NewCaptcha(); return Page(); } + if (string.IsNullOrWhiteSpace(Title)) { Error = "عنوان موقعیت الزامی است."; NewCaptcha(); return Page(); } + + // 2. Garbage screen. + if (SubmissionGuard.LooksLikeGarbage(Title, Description)) + { Error = "متن آگهی نامعتبر یا تبلیغاتی به‌نظر می‌رسد."; NewCaptcha(); return Page(); } + + // 3. Duplicate position. + if (await _guard.DuplicateJobAsync(FacilityId, RoleId, Title)) + { Error = "این موقعیت استخدامی قبلاً برای این مرکز ثبت شده است."; NewCaptcha(); return Page(); } _db.JobOpenings.Add(new JobOpening { @@ -66,4 +88,11 @@ public class PostJobModel : PageModel .Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); } + + private void NewCaptcha() + { + var (q, token) = _captcha.Create(); + CaptchaQuestion = q; + CaptchaToken = token; + } } diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml index 672b98a..9e63447 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml @@ -84,6 +84,11 @@ +
+ + + +
} diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs index d8a1a39..ea982f9 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using JobsMedical.Web.Data; using JobsMedical.Web.Models; +using JobsMedical.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -12,11 +13,22 @@ namespace JobsMedical.Web.Pages.Employer; public class PostShiftModel : PageModel { private readonly AppDbContext _db; - public PostShiftModel(AppDbContext db) => _db = db; + private readonly CaptchaService _captcha; + private readonly SubmissionGuard _guard; + + public PostShiftModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard) + { + _db = db; + _captcha = captcha; + _guard = guard; + } public List MyFacilities { get; private set; } = new(); public List Roles { get; private set; } = new(); public string? Error { get; private set; } + public string CaptchaQuestion { get; private set; } = ""; + [BindProperty] public string? CaptchaToken { get; set; } + [BindProperty] public string? CaptchaAnswer { get; set; } [BindProperty] public int FacilityId { get; set; } [BindProperty] public int RoleId { get; set; } @@ -36,16 +48,25 @@ public class PostShiftModel : PageModel Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1); StartTime = new TimeOnly(8, 0); EndTime = new TimeOnly(14, 0); + NewCaptcha(); } public async Task OnPostAsync() { await LoadListsAsync(); + + if (!_captcha.Verify(CaptchaToken, CaptchaAnswer)) + { Error = "پاسخ سؤال امنیتی نادرست است."; NewCaptcha(); return Page(); } + if (!MyFacilities.Any(f => f.Id == FacilityId)) - { - Error = "این مرکز متعلق به شما نیست."; - return Page(); - } + { Error = "این مرکز متعلق به شما نیست."; NewCaptcha(); return Page(); } + + if (SubmissionGuard.ContainsSpam(Description)) + { Error = "متن شیفت تبلیغاتی/نامعتبر به‌نظر می‌رسد."; NewCaptcha(); return Page(); } + + if (await _guard.DuplicateShiftAsync(FacilityId, RoleId, Date, StartTime, ShiftType)) + { Error = "این شیفت (همان مرکز، نقش، تاریخ و ساعت) قبلاً ثبت شده است."; NewCaptcha(); return Page(); } + var role = await _db.Roles.FindAsync(RoleId); _db.Shifts.Add(new Shift { @@ -76,4 +97,11 @@ public class PostShiftModel : PageModel .Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); } + + private void NewCaptcha() + { + var (q, token) = _captcha.Create(); + CaptchaQuestion = q; + CaptchaToken = token; + } } diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 8265c02..00147cc 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -18,6 +18,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Listing parser: heuristic now; swap for an LLM-backed IListingParser later. builder.Services.AddSingleton(); diff --git a/src/JobsMedical.Web/Services/CaptchaService.cs b/src/JobsMedical.Web/Services/CaptchaService.cs new file mode 100644 index 0000000..f7b1d87 --- /dev/null +++ b/src/JobsMedical.Web/Services/CaptchaService.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.DataProtection; + +namespace JobsMedical.Web.Services; + +/// +/// A built-in, stateless math CAPTCHA (no Google reCAPTCHA — it's blocked in Iran). It renders a +/// simple "a + b = ?" question plus a tamper-proof token (the answer + timestamp, data-protected). +/// Verification unprotects the token, checks it hasn't expired, and compares the answer. Because +/// the answer is encrypted in the token (not guessable/forgeable) and no server session is needed, +/// it works across the load-balanced/containerized deploy with zero extra storage. +/// +public class CaptchaService +{ + private readonly IDataProtector _protector; + private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10); + + public CaptchaService(IDataProtectionProvider dp) => _protector = dp.CreateProtector("hamkadr.captcha.v1"); + + /// Returns the question to show (e.g. "۳ + ۵") and an opaque token for a hidden field. + public (string Question, string Token) Create() + { + var a = Random.Shared.Next(1, 10); + var b = Random.Shared.Next(1, 10); + var token = _protector.Protect($"{a + b}|{DateTime.UtcNow.Ticks}"); + return ($"{ToPersian(a)} + {ToPersian(b)}", token); + } + + public bool Verify(string? token, string? answer) + { + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(answer)) return false; + try + { + var parts = _protector.Unprotect(token).Split('|'); + if (parts.Length != 2) return false; + if (long.TryParse(parts[1], out var ticks)) + { + var issued = new DateTime(ticks, DateTimeKind.Utc); + if (DateTime.UtcNow - issued > Ttl) return false; // expired + } + return parts[0] == ToLatin(answer).Trim(); + } + catch { return false; } // tampered / undecryptable + } + + private static string ToPersian(int n) + => new string(n.ToString().Select(c => (char)('۰' + (c - '0'))).ToArray()); + + private static string ToLatin(string s) + { + var chars = s.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (chars[i] >= '۰' && chars[i] <= '۹') chars[i] = (char)('0' + (chars[i] - '۰')); + else if (chars[i] >= '٠' && chars[i] <= '٩') chars[i] = (char)('0' + (chars[i] - '٠')); + } + return new string(chars); + } +} diff --git a/src/JobsMedical.Web/Services/InterestService.cs b/src/JobsMedical.Web/Services/InterestService.cs index 50a3956..896abe4 100644 --- a/src/JobsMedical.Web/Services/InterestService.cs +++ b/src/JobsMedical.Web/Services/InterestService.cs @@ -41,6 +41,10 @@ public class InterestService public async Task LogAsync(InterestEventType type, int shiftId) { if (string.IsNullOrEmpty(VisitorId)) return; + // Don't record the same applicant applying to the same shift twice (garbage prevention). + if (type == InterestEventType.Apply && await _db.InterestEvents.AnyAsync(e => + e.VisitorId == VisitorId && e.ShiftId == shiftId && e.EventType == InterestEventType.Apply)) + return; await EnsureVisitorAsync(); _db.InterestEvents.Add(new InterestEvent { @@ -54,6 +58,9 @@ public class InterestService public async Task LogJobAsync(InterestEventType type, int jobOpeningId) { if (string.IsNullOrEmpty(VisitorId)) return; + if (type == InterestEventType.Apply && await _db.InterestEvents.AnyAsync(e => + e.VisitorId == VisitorId && e.JobOpeningId == jobOpeningId && e.EventType == InterestEventType.Apply)) + return; await EnsureVisitorAsync(); _db.InterestEvents.Add(new InterestEvent { diff --git a/src/JobsMedical.Web/Services/SubmissionGuard.cs b/src/JobsMedical.Web/Services/SubmissionGuard.cs new file mode 100644 index 0000000..33afc51 --- /dev/null +++ b/src/JobsMedical.Web/Services/SubmissionGuard.cs @@ -0,0 +1,64 @@ +using System.Text.RegularExpressions; +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Services; + +/// +/// Anti-garbage gate for user-submitted listings: blocks duplicate positions, screens obvious +/// spam/garbage text, and prevents the same applicant applying to a listing twice. +/// +public class SubmissionGuard +{ + private readonly AppDbContext _db; + public SubmissionGuard(AppDbContext db) => _db = db; + + private static readonly string[] SpamMarkers = + { + "سرمایه گذاری", "سرمایه‌گذاری", "وام", "ارز دیجیتال", "رمز ارز", "فروش فالوور", + "بک لینک", "قرعه کشی", "کازینو", "شرط بندی", "بیت کوین", "http://", "https://", "www." + }; + + /// Spam/scam markers, mashed-keyboard, or stray links in free text (any field). + public static bool ContainsSpam(string? text) + { + var t = (text ?? "").Trim(); + if (t.Length == 0) return false; + if (SpamMarkers.Any(m => t.Contains(m))) return true; + if (Regex.IsMatch(t, @"(.)\1{6,}")) return true; // aaaaaaa / کککککککک + return false; + } + + /// For jobs: a real title is required, plus the spam screen on title+description. + public static bool LooksLikeGarbage(string? title, string? description) + { + if (Normalize(title).Length < 3) return true; // no real title + return ContainsSpam(title) || ContainsSpam(description); + } + + /// A near-identical OPEN shift already exists at this facility? + public Task DuplicateShiftAsync(int facilityId, int roleId, DateOnly date, + TimeOnly start, ShiftType type) => + _db.Shifts.AnyAsync(s => s.Status == ShiftStatus.Open && s.FacilityId == facilityId + && s.RoleId == roleId && s.Date == date && s.StartTime == start && s.ShiftType == type); + + /// A near-identical OPEN job already exists at this facility (same role + title)? + public async Task DuplicateJobAsync(int facilityId, int roleId, string title) + { + var t = Normalize(title); + var candidates = await _db.JobOpenings + .Where(j => j.Status == ShiftStatus.Open && j.FacilityId == facilityId && j.RoleId == roleId) + .Select(j => j.Title).ToListAsync(); + return candidates.Any(c => Normalize(c) == t); + } + + /// Has this visitor already applied (Apply event) to this shift/job? + public Task AlreadyAppliedAsync(string visitorId, int? shiftId, int? jobId) => + _db.InterestEvents.AnyAsync(e => e.VisitorId == visitorId + && e.EventType == InterestEventType.Apply + && ((shiftId != null && e.ShiftId == shiftId) || (jobId != null && e.JobOpeningId == jobId))); + + private static string Normalize(string? s) => Regex.Replace((s ?? "").Trim(), @"\s+", " ") + .Replace('ي', 'ی').Replace('ك', 'ک').ToLowerInvariant(); +}