This commit is contained in:
2026-05-06 14:48:12 +03:00
parent f1e8a9f8da
commit 711810d8c2
6 changed files with 109 additions and 82 deletions
+66
View File
@@ -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
{
/// <summary>
/// Endpoints that expose captcha configuration and verification.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class CaptchaController : ControllerBase
{
private readonly CaptchaSettings _captchaSettings;
private readonly ICaptchaVerifier _captcha;
private readonly ILogger<CaptchaController> _log;
public CaptchaController(IOptions<CaptchaSettings> options, ICaptchaVerifier captcha, ILogger<CaptchaController> log)
{
_captchaSettings = options.Value;
_captcha = captcha;
_log = log;
}
/// <summary>
/// Returns the public reCAPTCHA site key used by the client to render the widget.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get reCAPTCHA site key")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetSiteKey()
{
return Ok(_captchaSettings.PublicKey);
}
/// <summary>
/// Verify a captcha token and return the verification verdict.
/// </summary>
[HttpPost("verify")]
[SwaggerOperation(Summary = "Verify captcha token")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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; }
}
}
}
+9 -30
View File
@@ -37,11 +37,7 @@ namespace Api.Controllers
/// the reCAPTCHA widget and obtain client-side tokens.
/// </summary>
/// <returns>200 OK with the public site key as a string.</returns>
[HttpGet]
public async Task<IActionResult> GetReCaptchaSiteKey(CancellationToken ct)
{
return Ok(_captchaSettings.PublicKey);
}
// ReCaptcha endpoints have been extracted to CaptchaController
/// <summary>
/// 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.");
}
}
/// <summary>
/// Helper that runs reCAPTCHA verification for the supplied token and
/// returns the verdict along with the resolved user IP address.
/// </summary>
/// <param name="token">Client-provided reCAPTCHA token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Tuple containing the verification verdict and user IP.</returns>
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.
}
}
+8 -21
View File
@@ -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<CvMatcherController> _logger;
public CvMatcherController(
Api.Clients.Api.Contracts.ICvMatcherApi cvApi,
ICvMatcherApi cvApi,
IConfiguration configuration,
ILogger<CvMatcherController> logger)
{
@@ -34,6 +35,7 @@ public sealed class CvMatcherController : ControllerBase
/// <param name="gdprConsent">Whether the user consented to GDPR processing.</param>
/// <param name="ct">Cancellation token.</param>
[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<IActionResult> 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<IActionResult> 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<ContentResult> ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct)
+12
View File
@@ -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; }
}
}
+1 -18
View File
@@ -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}
+13 -13
View File
@@ -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) {