Add anti-abuse: built-in captcha + garbage/duplicate guard
CI/CD / CI · dotnet build (push) Successful in 1m22s
CI/CD / Deploy · hamkadr (push) Successful in 1m36s

- CaptchaService: stateless data-protected math captcha (no Google reCAPTCHA — blocked in Iran), TTL + Persian-digit tolerant; on PostJob + PostShift
- SubmissionGuard: duplicate-position detection (facility+role+date/time for shifts, facility+role+title for jobs), spam/garbage screen on title/description, double-apply prevention
- InterestService: Apply events deduped so an applicant can't apply to the same listing twice
- Verified: wrong captcha rejected, correct publishes, duplicate + garbage blocked

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 06:35:17 +03:30
parent e2e26150cb
commit 0587e040d9
8 changed files with 210 additions and 12 deletions
@@ -0,0 +1,64 @@
using System.Text.RegularExpressions;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Services;
/// <summary>
/// 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.
/// </summary>
public class SubmissionGuard
{
private readonly AppDbContext _db;
public SubmissionGuard(AppDbContext db) => _db = db;
private static readonly string[] SpamMarkers =
{
"سرمایه گذاری", "سرمایه‌گذاری", "وام", "ارز دیجیتال", "رمز ارز", "فروش فالوور",
"بک لینک", "قرعه کشی", "کازینو", "شرط بندی", "بیت کوین", "http://", "https://", "www."
};
/// <summary>Spam/scam markers, mashed-keyboard, or stray links in free text (any field).</summary>
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;
}
/// <summary>For jobs: a real title is required, plus the spam screen on title+description.</summary>
public static bool LooksLikeGarbage(string? title, string? description)
{
if (Normalize(title).Length < 3) return true; // no real title
return ContainsSpam(title) || ContainsSpam(description);
}
/// <summary>A near-identical OPEN shift already exists at this facility?</summary>
public Task<bool> 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);
/// <summary>A near-identical OPEN job already exists at this facility (same role + title)?</summary>
public async Task<bool> 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);
}
/// <summary>Has this visitor already applied (Apply event) to this shift/job?</summary>
public Task<bool> 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();
}