diff --git a/api/Controllers/CaptchaController.cs b/api/Controllers/CaptchaController.cs index b80edea..84b0c55 100644 --- a/api/Controllers/CaptchaController.cs +++ b/api/Controllers/CaptchaController.cs @@ -1,9 +1,9 @@ -using Api.Services.Contracts.Models; using Api.Services.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Api.Models.Settings; using Swashbuckle.AspNetCore.Annotations; +using Api.Models.Requests; namespace Api.Controllers { @@ -29,7 +29,7 @@ namespace Api.Controllers /// Returns the public reCAPTCHA site key used by the client to render the widget. /// [HttpGet] - [SwaggerOperation(Summary = "Get reCAPTCHA site key")] + [SwaggerOperation(Summary = "Get captcha site key")] [ProducesResponseType(StatusCodes.Status200OK)] public IActionResult GetSiteKey() { @@ -43,7 +43,7 @@ namespace Api.Controllers [SwaggerOperation(Summary = "Verify captcha token")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task Verify([FromBody] VerifyRequest req, CancellationToken ct) + public async Task Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct) { if (req is null || string.IsNullOrWhiteSpace(req.Token)) return BadRequest(new { error = "Missing token" }); @@ -57,10 +57,5 @@ namespace Api.Controllers return Ok(verdict); } - - public sealed class VerifyRequest - { - public string? Token { get; set; } - } } } diff --git a/api/Controllers/ContactController.cs b/api/Controllers/ContactController.cs index 4ed53ca..dd49cd6 100644 --- a/api/Controllers/ContactController.cs +++ b/api/Controllers/ContactController.cs @@ -19,26 +19,16 @@ namespace Api.Controllers [EnableCors("FrontendOnly")] public sealed class ContactController : ControllerBase { - private readonly CaptchaSettings _captchaSettings; private readonly ICaptchaVerifier _captcha; private readonly IEmailSender _email; private readonly ILogger _log; - public ContactController(IOptions options, ICaptchaVerifier captcha, IEmailSender email, ILogger log) + public ContactController(ICaptchaVerifier captcha, IEmailSender email, ILogger log) { - _captchaSettings = options.Value; _captcha = captcha; _email = email; _log = log; } - - /// - /// Returns the public reCAPTCHA site key used by the client to render - /// the reCAPTCHA widget and obtain client-side tokens. - /// - /// 200 OK with the public site key as a string. - // ReCaptcha endpoints have been extracted to CaptchaController - /// /// Validates the provided reCAPTCHA token and sends a contact message /// via the configured email sender. @@ -107,7 +97,5 @@ namespace Api.Controllers } } - - // Captcha verification helper was moved to CaptchaController; ContactController calls _captcha.VerifyAsync directly. } } diff --git a/api/Controllers/CvMatcherController.cs b/api/Controllers/CvMatcherController.cs index 4a8abfb..7d31bbb 100644 --- a/api/Controllers/CvMatcherController.cs +++ b/api/Controllers/CvMatcherController.cs @@ -1,5 +1,6 @@ using Api.Clients.Api.Contracts; using Api.Models.Requests; +using Api.Services.Contracts; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Swashbuckle.AspNetCore.Annotations; @@ -16,23 +17,25 @@ public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; private readonly IConfiguration _configuration; + private readonly ICaptchaVerifier _captcha; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, IConfiguration configuration, + ICaptchaVerifier captcha, ILogger logger) { _cvApi = cvApi; _configuration = configuration; + _captcha = captcha; _logger = logger; } /// /// Upload a CV PDF to the cv-matcher-api. /// - /// The uploaded CV PDF file. - /// Whether the user consented to GDPR processing. + /// The uploaded CV request. /// Cancellation token. [HttpPost("upload")] [Consumes("multipart/form-data")] @@ -61,6 +64,14 @@ public sealed class CvMatcherController : ControllerBase _logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}", cv.FileName, cv.Length, gdprConsent); + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var verdict = await _captcha.VerifyAsync(request.CaptchaToken ?? string.Empty, userIp, ct); + if (!verdict.Success) + { + _logger.LogWarning("Captcha verification failed for CV upload. IP={IP}", userIp); + return BadRequest(new { error = "Captcha verification failed." }); + } + var stream = cv.OpenReadStream(); var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf"); using var response = await _cvApi.Upload(part, gdprConsent); @@ -100,6 +111,14 @@ public sealed class CvMatcherController : ControllerBase !string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobDescription)); + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var verdict = await _captcha.VerifyAsync(request.CaptchaToken ?? string.Empty, userIp, ct); + if (!verdict.Success) + { + _logger.LogWarning("Captcha verification failed for job match. IP={IP}", userIp); + return BadRequest(new { error = "Captcha verification failed." }); + } + using var response = await _cvApi.MatchJob(request); return await ProxyResponseAsync(response, ct); } diff --git a/api/Models/Requests/CaptchaVerifyRequest.cs b/api/Models/Requests/CaptchaVerifyRequest.cs new file mode 100644 index 0000000..836b348 --- /dev/null +++ b/api/Models/Requests/CaptchaVerifyRequest.cs @@ -0,0 +1,8 @@ +namespace Api.Models.Requests +{ + public class CaptchaVerifyRequest + { + public string? Token { get; set; } + + } +} diff --git a/api/Models/Requests/JobMatchRequest.cs b/api/Models/Requests/JobMatchRequest.cs index 04b4a77..9f3bd35 100644 --- a/api/Models/Requests/JobMatchRequest.cs +++ b/api/Models/Requests/JobMatchRequest.cs @@ -6,4 +6,5 @@ public sealed class JobMatchRequest public string? JobUrl { get; set; } public string? JobDescription { get; set; } public bool GdprConsent { get; set; } + public string? CaptchaToken { get; set; } } diff --git a/api/Models/Requests/UploadCvRequest.cs b/api/Models/Requests/UploadCvRequest.cs index 2c3d930..b98b503 100644 --- a/api/Models/Requests/UploadCvRequest.cs +++ b/api/Models/Requests/UploadCvRequest.cs @@ -8,5 +8,6 @@ namespace Api.Models.Requests public IFormFile Cv { get; set; } = default!; public bool GdprConsent { get; set; } + public string? CaptchaToken { get; set; } } } diff --git a/cv-matcher-api/Models/Requests/MatchJobRequest.cs b/cv-matcher-api/Models/Requests/MatchJobRequest.cs index 02b16bd..63bd70a 100644 --- a/cv-matcher-api/Models/Requests/MatchJobRequest.cs +++ b/cv-matcher-api/Models/Requests/MatchJobRequest.cs @@ -7,5 +7,6 @@ public string? JobDescription { get; set; } public bool GdprConsent { get; set; } public string? Email { get; set; } + public string? CaptchaToken { get; set; } } } diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index d1ea66e..016e268 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -29,7 +29,6 @@ services: dockerfile: Dockerfile container_name: myai-cv-matcher-api depends_on: - - mssql - rag-api ports: - "8082:8080" @@ -92,9 +91,6 @@ services: - myai-network restart: unless-stopped -volumes: - myai-mssql-data: - networks: myai-network: driver: bridge \ No newline at end of file diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 7bf5832..78429e9 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -419,7 +419,12 @@ }).then(postContact); }); } else { - postContact(''); + // Captcha unavailable: show clear error and restore UI + submitMSG(false, t('form.captchaFailed')); + formError(); + loader.hide(); + button.prop('disabled', false); + return; } }); @@ -460,20 +465,24 @@ }).then(postCv); }); } else { - postCv(''); + // Captcha unavailable: show clear error and restore UI + $msg.removeClass().addClass('form-message text-danger').text(t('form.captchaFailed')); + $button.prop('disabled', false).text(t('cv.submit')); + return; } function postCv(token) { try { var formData = new FormData(); formData.append('cv', file); formData.append('gdprConsent', String(consent)); + formData.append('captchaToken', token || ''); var cvResponse = await fetch('/api/cv-matcher/upload', { method: 'POST', body: formData }); if (!cvResponse.ok) throw new Error(t('cv.cvFailed')); var cvData = await cvResponse.json(); - var matchResponse = await fetch('/api/cv-matcher/match-job', { + var matchResponse = await fetch('/api/cv-matcher/match-job', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -482,7 +491,8 @@ cvDocumentId: cvData.documentId || cvData.cvDocumentId, jobUrl: jobUrl, jobDescription: jobDescription, - gdprConsent: consent + gdprConsent: consent, + captchaToken: token }) }); if (!matchResponse.ok) throw new Error(t('cv.matchFailed'));