diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs index ec041c8..374acfb 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs @@ -63,6 +63,11 @@ public class PostJobModel : PageModel if (await _guard.DuplicateJobAsync(FacilityId, RoleId, Title)) { Error = "این موقعیت استخدامی قبلاً برای این مرکز ثبت شده است."; NewCaptcha(); return Page(); } + // 4. Flood protection — hourly posting cap. + var uid = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + if (await _guard.PostingRateExceededAsync(uid)) + { Error = $"در یک ساعت اخیر بیش از حد مجاز ({SubmissionGuard.MaxListingsPerHour}) آگهی ثبت کرده‌اید. بعداً تلاش کنید."; NewCaptcha(); return Page(); } + _db.JobOpenings.Add(new JobOpening { FacilityId = FacilityId, diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs index ea982f9..02be91c 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs @@ -67,6 +67,10 @@ public class PostShiftModel : PageModel if (await _guard.DuplicateShiftAsync(FacilityId, RoleId, Date, StartTime, ShiftType)) { Error = "این شیفت (همان مرکز، نقش، تاریخ و ساعت) قبلاً ثبت شده است."; NewCaptcha(); return Page(); } + var uid = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + if (await _guard.PostingRateExceededAsync(uid)) + { Error = $"در یک ساعت اخیر بیش از حد مجاز ({SubmissionGuard.MaxListingsPerHour}) آگهی ثبت کرده‌اید. بعداً تلاش کنید."; NewCaptcha(); return Page(); } + var role = await _db.Roles.FindAsync(RoleId); _db.Shifts.Add(new Shift { diff --git a/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml index f3ec03e..4c6029a 100644 --- a/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml +++ b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml @@ -62,6 +62,11 @@

مختصات برای نمایش در فیلتر «نزدیک من» استفاده می‌شود.

+
+ + + +
diff --git a/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs index 18bbf25..cb8dea5 100644 --- a/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/RegisterFacility.cshtml.cs @@ -15,10 +15,18 @@ namespace JobsMedical.Web.Pages.Employer; public class RegisterFacilityModel : PageModel { private readonly AppDbContext _db; - public RegisterFacilityModel(AppDbContext db) => _db = db; + private readonly CaptchaService _captcha; + public RegisterFacilityModel(AppDbContext db, CaptchaService captcha) + { + _db = db; + _captcha = captcha; + } public List Cities { get; private set; } = new(); public List Districts { get; private set; } = new(); + public string CaptchaQuestion { get; private set; } = ""; + [BindProperty] public string? CaptchaToken { get; set; } + [BindProperty] public string? CaptchaAnswer { get; set; } [BindProperty] public string Name { get; set; } = ""; [BindProperty] public FacilityType Type { get; set; } @@ -31,16 +39,21 @@ public class RegisterFacilityModel : PageModel [BindProperty] public double? Lng { get; set; } public string? Error { get; private set; } - public async Task OnGetAsync() => await LoadListsAsync(); + public async Task OnGetAsync() { await LoadListsAsync(); NewCaptcha(); } public async Task OnPostAsync() { await LoadListsAsync(); + if (!_captcha.Verify(CaptchaToken, CaptchaAnswer)) + { Error = "پاسخ سؤال امنیتی نادرست است."; NewCaptcha(); return Page(); } if (string.IsNullOrWhiteSpace(Name) || CityId == 0) { Error = "نام مرکز و شهر الزامی است."; + NewCaptcha(); return Page(); } + if (SubmissionGuard.ContainsSpam(Name)) + { Error = "نام مرکز نامعتبر به‌نظر می‌رسد."; NewCaptcha(); return Page(); } var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); var facility = new Facility @@ -81,4 +94,11 @@ public class RegisterFacilityModel : PageModel Cities = await _db.Cities.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name).ToListAsync(); Districts = await _db.Districts.Where(d => d.IsActive).OrderBy(d => d.Name).ToListAsync(); } + + private void NewCaptcha() + { + var (q, token) = _captcha.Create(); + CaptchaQuestion = q; + CaptchaToken = token; + } } diff --git a/src/JobsMedical.Web/Services/SubmissionGuard.cs b/src/JobsMedical.Web/Services/SubmissionGuard.cs index 33afc51..eb829ae 100644 --- a/src/JobsMedical.Web/Services/SubmissionGuard.cs +++ b/src/JobsMedical.Web/Services/SubmissionGuard.cs @@ -53,6 +53,18 @@ public class SubmissionGuard return candidates.Any(c => Normalize(c) == t); } + /// Max new listings (shifts + jobs) one account may post per rolling hour. + public const int MaxListingsPerHour = 20; + + /// True if this owner has hit the hourly posting cap (flood protection). + public async Task PostingRateExceededAsync(int ownerUserId) + { + var since = DateTime.UtcNow.AddHours(-1); + var shifts = await _db.Shifts.CountAsync(s => s.Facility.OwnerUserId == ownerUserId && s.CreatedAt >= since); + var jobs = await _db.JobOpenings.CountAsync(j => j.Facility.OwnerUserId == ownerUserId && j.CreatedAt >= since); + return shifts + jobs >= MaxListingsPerHour; + } + /// 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