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) {