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