From 711810d8c2893f32f38ca7a21a1720f71f5cbe44 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Wed, 6 May 2026 14:48:12 +0300 Subject: [PATCH] Changes --- api/Controllers/CaptchaController.cs | 66 ++++++++++++++++++++++++++ api/Controllers/ContactController.cs | 39 ++++----------- api/Controllers/CvMatcherController.cs | 29 ++++------- api/Models/Requests/UploadCvRequest.cs | 12 +++++ docker-compose/docker-compose.yml | 19 +------- web/wwwroot/js/main.js | 26 +++++----- 6 files changed, 109 insertions(+), 82 deletions(-) create mode 100644 api/Controllers/CaptchaController.cs create mode 100644 api/Models/Requests/UploadCvRequest.cs diff --git a/api/Controllers/CaptchaController.cs b/api/Controllers/CaptchaController.cs new file mode 100644 index 0000000..b80edea --- /dev/null +++ b/api/Controllers/CaptchaController.cs @@ -0,0 +1,66 @@ +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; + +namespace Api.Controllers +{ + /// + /// Endpoints that expose captcha configuration and verification. + /// + [ApiController] + [Route("api/[controller]")] + public sealed class CaptchaController : ControllerBase + { + private readonly CaptchaSettings _captchaSettings; + private readonly ICaptchaVerifier _captcha; + private readonly ILogger _log; + + public CaptchaController(IOptions options, ICaptchaVerifier captcha, ILogger log) + { + _captchaSettings = options.Value; + _captcha = captcha; + _log = log; + } + + /// + /// Returns the public reCAPTCHA site key used by the client to render the widget. + /// + [HttpGet] + [SwaggerOperation(Summary = "Get reCAPTCHA site key")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetSiteKey() + { + return Ok(_captchaSettings.PublicKey); + } + + /// + /// Verify a captcha token and return the verification verdict. + /// + [HttpPost("verify")] + [SwaggerOperation(Summary = "Verify captcha token")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Verify([FromBody] VerifyRequest req, CancellationToken ct) + { + if (req is null || string.IsNullOrWhiteSpace(req.Token)) return BadRequest(new { error = "Missing token" }); + + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var verdict = await _captcha.VerifyAsync(req.Token, userIp, ct); + if (!verdict.Success) + { + _log.LogWarning("Captcha failed. ip={Ip} score={Score} err={Err}", userIp, verdict.Score, verdict.Error); + return BadRequest(new { error = "Captcha verification failed.", score = verdict.Score }); + } + + 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 a3ff245..4ed53ca 100644 --- a/api/Controllers/ContactController.cs +++ b/api/Controllers/ContactController.cs @@ -37,11 +37,7 @@ namespace Api.Controllers /// the reCAPTCHA widget and obtain client-side tokens. /// /// 200 OK with the public site key as a string. - [HttpGet] - public async Task GetReCaptchaSiteKey(CancellationToken ct) - { - return Ok(_captchaSettings.PublicKey); - } + // ReCaptcha endpoints have been extracted to CaptchaController /// /// Validates the provided reCAPTCHA token and sends a contact message @@ -62,9 +58,8 @@ namespace Api.Controllers return ValidationProblem(ModelState); var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); - - var res = await ValidateCaptcha(req.CaptchaToken, ct); - if (!res.Verdict.Success) return BadRequest("Captcha verification failed."); + var verdict = await _captcha.VerifyAsync(req.CaptchaToken, userIp, ct); + if (!verdict.Success) return BadRequest("Captcha verification failed."); try { @@ -73,7 +68,7 @@ namespace Api.Controllers } catch (Exception ex) { - _log.LogError(ex, "Contact send failed. ip={Ip} from={From}", res.UserIp, req.Email); + _log.LogError(ex, "Contact send failed. ip={Ip} from={From}", userIp, req.Email); return StatusCode(500, "Could not send message."); } } @@ -96,8 +91,9 @@ namespace Api.Controllers if (!ModelState.IsValid) return ValidationProblem(ModelState); - var res = await ValidateCaptcha(req.CaptchaToken, ct); - if (!res.Verdict.Success) return BadRequest("Captcha verification failed."); + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var verdict = await _captcha.VerifyAsync(req.CaptchaToken, userIp, ct); + if (!verdict.Success) return BadRequest("Captcha verification failed."); try { @@ -106,29 +102,12 @@ namespace Api.Controllers } catch (Exception ex) { - _log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", res.UserIp, req.Email); + _log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email); return StatusCode(500, "Failed."); } } - /// - /// Helper that runs reCAPTCHA verification for the supplied token and - /// returns the verdict along with the resolved user IP address. - /// - /// Client-provided reCAPTCHA token. - /// Cancellation token. - /// Tuple containing the verification verdict and user IP. - private async Task<(CaptchaVerdictModel Verdict, string? UserIp)> ValidateCaptcha(string token, CancellationToken ct) - { - var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); - var verdict = await _captcha.VerifyAsync(token, userIp, ct); - if (!verdict.Success) - { - _log.LogWarning("Captcha failed. ip={Ip} score={Score} err={Err}", - userIp, verdict.Score, verdict.Error); - } - return (verdict, userIp); - } + // 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 ad23f0b..4a8abfb 100644 --- a/api/Controllers/CvMatcherController.cs +++ b/api/Controllers/CvMatcherController.cs @@ -1,3 +1,4 @@ +using Api.Clients.Api.Contracts; using Api.Models.Requests; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -13,12 +14,12 @@ namespace Api.Controllers; [EnableRateLimiting("cv-matcher")] public sealed class CvMatcherController : ControllerBase { - private readonly Api.Clients.Api.Contracts.ICvMatcherApi _cvApi; + private readonly ICvMatcherApi _cvApi; private readonly IConfiguration _configuration; private readonly ILogger _logger; public CvMatcherController( - Api.Clients.Api.Contracts.ICvMatcherApi cvApi, + ICvMatcherApi cvApi, IConfiguration configuration, ILogger logger) { @@ -34,6 +35,7 @@ public sealed class CvMatcherController : ControllerBase /// Whether the user consented to GDPR processing. /// Cancellation token. [HttpPost("upload")] + [Consumes("multipart/form-data")] [RequestSizeLimit(8 * 1024 * 1024)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -43,21 +45,16 @@ public sealed class CvMatcherController : ControllerBase [SwaggerResponse(StatusCodes.Status400BadRequest, "Missing or invalid input")] [SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")] public async Task UploadCv( - [FromForm(Name = "cv")] IFormFile? cv, - [FromForm] bool gdprConsent, + [FromForm] UploadCvRequest request, CancellationToken ct) { - if (cv is null) + if (request.Cv is null) { return BadRequest(new { error = "Missing CV PDF." }); } - var baseUrl = GetCvMatcherBaseUrl(); - if (string.IsNullOrWhiteSpace(baseUrl)) - { - _logger.LogError("CvMatcherApi:BaseUrl is not configured. The public API cannot proxy CV upload requests."); - return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API is not configured." }); - } + var cv = request.Cv; + var gdprConsent = request.GdprConsent; try { @@ -96,13 +93,6 @@ public sealed class CvMatcherController : ControllerBase [SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")] public async Task MatchJob([FromBody] JobMatchRequest request, CancellationToken ct) { - var baseUrl = GetCvMatcherBaseUrl(); - if (string.IsNullOrWhiteSpace(baseUrl)) - { - _logger.LogError("CvMatcherApi:BaseUrl is not configured. The public API cannot proxy job matching requests."); - return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API is not configured." }); - } - try { _logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}", @@ -124,9 +114,6 @@ public sealed class CvMatcherController : ControllerBase return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API request failed." }); } } - - private string GetCvMatcherBaseUrl() => _configuration["CvMatcherApi:BaseUrl"] ?? string.Empty; - // Refit client is configured in Program.cs; this helper only reads config for diagnostics private static async Task ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct) diff --git a/api/Models/Requests/UploadCvRequest.cs b/api/Models/Requests/UploadCvRequest.cs new file mode 100644 index 0000000..2c3d930 --- /dev/null +++ b/api/Models/Requests/UploadCvRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Api.Models.Requests +{ + public sealed class UploadCvRequest + { + [Required] + public IFormFile Cv { get; set; } = default!; + + public bool GdprConsent { get; set; } + } +} diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 4daae6f..d1ea66e 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -1,28 +1,11 @@ version: "3.8" services: - - mssql: - image: mcr.microsoft.com/mssql/server:2022-latest - container_name: myai-mssql - environment: - - ACCEPT_EULA=Y - - MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD:-Your_strong_password123} - ports: - - "1433:1433" - volumes: - - myai-mssql-data:/var/opt/mssql - networks: - - myai-network - restart: unless-stopped - rag-api: build: context: ../rag-api dockerfile: Dockerfile container_name: myai-rag-api - depends_on: - - mssql ports: - "8081:8080" env_file: @@ -30,7 +13,7 @@ services: environment: - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - ASPNETCORE_URLS=http://+:8080 - - ConnectionStrings__RagDb=Server=mssql,1433;Database=MyAiRag;User Id=sa;Password=${MSSQL_SA_PASSWORD:-Your_strong_password123};TrustServerCertificate=True + - ConnectionStrings__RagDb=Server=mssql,1433;Database=MyAi;User Id=sa;Password=bpdTUyb3;TrustServerCertificate=True - InternalApi__RequireApiKey=true - InternalApi__ApiKey=${INTERNAL_API_KEY:-change-this-internal-key} - Ai__Provider=${AI_PROVIDER:-OpenAI} diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 26c8802..7bf5832 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -267,19 +267,19 @@ console.error('API health check failed:', textStatus, errorThrown); }); } - function getRecaptchaWebKey() { - return $.get('/api/contact').done(function (res) { - reCaptchaSiteKey = res; - if (reCaptchaSiteKey && !window.__recaptcha_loaded) { - window.__recaptcha_loaded = true; - var script = document.createElement('script'); - script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey); - document.head.appendChild(script); - } - }).fail(function () { - console.warn('Could not load reCaptcha site key from /api/contact'); - }); - } + function getRecaptchaWebKey() { + return $.get('/api/captcha').done(function (res) { + reCaptchaSiteKey = res; + if (reCaptchaSiteKey && !window.__recaptcha_loaded) { + window.__recaptcha_loaded = true; + var script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey); + document.head.appendChild(script); + } + }).fail(function () { + console.warn('Could not load reCaptcha site key from /api/captcha'); + }); + } function getGoogleTagManagerId() { return $.get('/api/google/tagmanager').done(function (res) {