This commit is contained in:
2026-05-06 15:17:20 +03:00
parent 711810d8c2
commit a10908364b
9 changed files with 50 additions and 31 deletions
+3 -8
View File
@@ -1,9 +1,9 @@
using Api.Services.Contracts.Models;
using Api.Services.Contracts; using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Api.Models.Settings; using Api.Models.Settings;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Api.Models.Requests;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -29,7 +29,7 @@ namespace Api.Controllers
/// Returns the public reCAPTCHA site key used by the client to render the widget. /// Returns the public reCAPTCHA site key used by the client to render the widget.
/// </summary> /// </summary>
[HttpGet] [HttpGet]
[SwaggerOperation(Summary = "Get reCAPTCHA site key")] [SwaggerOperation(Summary = "Get captcha site key")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetSiteKey() public IActionResult GetSiteKey()
{ {
@@ -43,7 +43,7 @@ namespace Api.Controllers
[SwaggerOperation(Summary = "Verify captcha token")] [SwaggerOperation(Summary = "Verify captcha token")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Verify([FromBody] VerifyRequest req, CancellationToken ct) public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct)
{ {
if (req is null || string.IsNullOrWhiteSpace(req.Token)) return BadRequest(new { error = "Missing token" }); if (req is null || string.IsNullOrWhiteSpace(req.Token)) return BadRequest(new { error = "Missing token" });
@@ -57,10 +57,5 @@ namespace Api.Controllers
return Ok(verdict); return Ok(verdict);
} }
public sealed class VerifyRequest
{
public string? Token { get; set; }
}
} }
} }
+1 -13
View File
@@ -19,26 +19,16 @@ namespace Api.Controllers
[EnableCors("FrontendOnly")] [EnableCors("FrontendOnly")]
public sealed class ContactController : ControllerBase public sealed class ContactController : ControllerBase
{ {
private readonly CaptchaSettings _captchaSettings;
private readonly ICaptchaVerifier _captcha; private readonly ICaptchaVerifier _captcha;
private readonly IEmailSender _email; private readonly IEmailSender _email;
private readonly ILogger<ContactController> _log; private readonly ILogger<ContactController> _log;
public ContactController(IOptions<CaptchaSettings> options, ICaptchaVerifier captcha, IEmailSender email, ILogger<ContactController> log) public ContactController(ICaptchaVerifier captcha, IEmailSender email, ILogger<ContactController> log)
{ {
_captchaSettings = options.Value;
_captcha = captcha; _captcha = captcha;
_email = email; _email = email;
_log = log; _log = log;
} }
/// <summary>
/// Returns the public reCAPTCHA site key used by the client to render
/// the reCAPTCHA widget and obtain client-side tokens.
/// </summary>
/// <returns>200 OK with the public site key as a string.</returns>
// ReCaptcha endpoints have been extracted to CaptchaController
/// <summary> /// <summary>
/// Validates the provided reCAPTCHA token and sends a contact message /// Validates the provided reCAPTCHA token and sends a contact message
/// via the configured email sender. /// via the configured email sender.
@@ -107,7 +97,5 @@ namespace Api.Controllers
} }
} }
// Captcha verification helper was moved to CaptchaController; ContactController calls _captcha.VerifyAsync directly.
} }
} }
+21 -2
View File
@@ -1,5 +1,6 @@
using Api.Clients.Api.Contracts; using Api.Clients.Api.Contracts;
using Api.Models.Requests; using Api.Models.Requests;
using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
@@ -16,23 +17,25 @@ public sealed class CvMatcherController : ControllerBase
{ {
private readonly ICvMatcherApi _cvApi; private readonly ICvMatcherApi _cvApi;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ICaptchaVerifier _captcha;
private readonly ILogger<CvMatcherController> _logger; private readonly ILogger<CvMatcherController> _logger;
public CvMatcherController( public CvMatcherController(
ICvMatcherApi cvApi, ICvMatcherApi cvApi,
IConfiguration configuration, IConfiguration configuration,
ICaptchaVerifier captcha,
ILogger<CvMatcherController> logger) ILogger<CvMatcherController> logger)
{ {
_cvApi = cvApi; _cvApi = cvApi;
_configuration = configuration; _configuration = configuration;
_captcha = captcha;
_logger = logger; _logger = logger;
} }
/// <summary> /// <summary>
/// Upload a CV PDF to the cv-matcher-api. /// Upload a CV PDF to the cv-matcher-api.
/// </summary> /// </summary>
/// <param name="cv">The uploaded CV PDF file.</param> /// <param name="request">The uploaded CV request.</param>
/// <param name="gdprConsent">Whether the user consented to GDPR processing.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
[HttpPost("upload")] [HttpPost("upload")]
[Consumes("multipart/form-data")] [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}", _logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}",
cv.FileName, cv.Length, 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 stream = cv.OpenReadStream();
var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf"); var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf");
using var response = await _cvApi.Upload(part, gdprConsent); using var response = await _cvApi.Upload(part, gdprConsent);
@@ -100,6 +111,14 @@ public sealed class CvMatcherController : ControllerBase
!string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobUrl),
!string.IsNullOrWhiteSpace(request.JobDescription)); !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); using var response = await _cvApi.MatchJob(request);
return await ProxyResponseAsync(response, ct); return await ProxyResponseAsync(response, ct);
} }
@@ -0,0 +1,8 @@
namespace Api.Models.Requests
{
public class CaptchaVerifyRequest
{
public string? Token { get; set; }
}
}
+1
View File
@@ -6,4 +6,5 @@ public sealed class JobMatchRequest
public string? JobUrl { get; set; } public string? JobUrl { get; set; }
public string? JobDescription { get; set; } public string? JobDescription { get; set; }
public bool GdprConsent { get; set; } public bool GdprConsent { get; set; }
public string? CaptchaToken { get; set; }
} }
+1
View File
@@ -8,5 +8,6 @@ namespace Api.Models.Requests
public IFormFile Cv { get; set; } = default!; public IFormFile Cv { get; set; } = default!;
public bool GdprConsent { get; set; } public bool GdprConsent { get; set; }
public string? CaptchaToken { get; set; }
} }
} }
@@ -7,5 +7,6 @@
public string? JobDescription { get; set; } public string? JobDescription { get; set; }
public bool GdprConsent { get; set; } public bool GdprConsent { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? CaptchaToken { get; set; }
} }
} }
-4
View File
@@ -29,7 +29,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: myai-cv-matcher-api container_name: myai-cv-matcher-api
depends_on: depends_on:
- mssql
- rag-api - rag-api
ports: ports:
- "8082:8080" - "8082:8080"
@@ -92,9 +91,6 @@ services:
- myai-network - myai-network
restart: unless-stopped restart: unless-stopped
volumes:
myai-mssql-data:
networks: networks:
myai-network: myai-network:
driver: bridge driver: bridge
+14 -4
View File
@@ -419,7 +419,12 @@
}).then(postContact); }).then(postContact);
}); });
} else { } 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); }).then(postCv);
}); });
} else { } 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) { function postCv(token) {
try { try {
var formData = new FormData(); var formData = new FormData();
formData.append('cv', file); formData.append('cv', file);
formData.append('gdprConsent', String(consent)); formData.append('gdprConsent', String(consent));
formData.append('captchaToken', token || '');
var cvResponse = await fetch('/api/cv-matcher/upload', { var cvResponse = await fetch('/api/cv-matcher/upload', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
if (!cvResponse.ok) throw new Error(t('cv.cvFailed')); if (!cvResponse.ok) throw new Error(t('cv.cvFailed'));
var cvData = await cvResponse.json(); 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -482,7 +491,8 @@
cvDocumentId: cvData.documentId || cvData.cvDocumentId, cvDocumentId: cvData.documentId || cvData.cvDocumentId,
jobUrl: jobUrl, jobUrl: jobUrl,
jobDescription: jobDescription, jobDescription: jobDescription,
gdprConsent: consent gdprConsent: consent,
captchaToken: token
}) })
}); });
if (!matchResponse.ok) throw new Error(t('cv.matchFailed')); if (!matchResponse.ok) throw new Error(t('cv.matchFailed'));