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();
+}